From ae6f183b96f169d8cc99bb0686bedc7ab0216ce9 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:46:14 +0545 Subject: [PATCH 01/56] feat(competition): add D1 read helpers for trading-comp surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-A — read-side helpers for the verifier surface. Pure SQL functions over the swaps + agents + registered_wallets substrate shipped in #668 (migrations 005-007). No HTTP, no logging side-effects — routes compose them. - getCompetitionStatusFromD1: JOIN registered_wallets + agents + swaps; synthesizes registered:false row when address is not in membership view (handoff hard constraint — do NOT 404 on miss). - listSwapsFromD1: keyset pagination over (burn_block_time, txid) with strict-less-than predicate so pages are stable under concurrent inserts (unlike OFFSET). - countSwapsFromD1: total swaps for sender (route uses for hasMore). - encodeSwapsCursor / decodeSwapsCursor: opaque base64url codec. Field names mirror migration 005 (sender, token_in, amount_in, burn_block_time, source) — not the #683 spec. source enum is 'agent' | 'cron' | 'chainhook' per the handoff. Mirrors the pattern from lib/inbox/d1-reads.ts (PR #722). Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/d1-reads.ts | 270 ++++++++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 lib/competition/d1-reads.ts diff --git a/lib/competition/d1-reads.ts b/lib/competition/d1-reads.ts new file mode 100644 index 00000000..c9214dc6 --- /dev/null +++ b/lib/competition/d1-reads.ts @@ -0,0 +1,270 @@ +/** + * D1 read helpers for the trading-comp verifier surface. + * + * Phase 3.1 PR-A — read routes only. The verifier (POST /api/competition/trades) + * ships in PR-B; chainhook + cron + allowlist in PR-C + PR-D. + * + * Read contract (locked, per PHASE-3.1-HANDOFF.md): + * - GET /api/competition/status → getCompetitionStatusFromD1 + * - GET /api/competition/trades → listSwapsFromD1 + countSwapsFromD1 + * + * Schema reference: migrations/005_swaps.sql, 001_agents.sql, + * 007_registered_wallets_view.sql. + * + * See: https://github.com/aibtcdev/landing-page/issues/734 (Phase 3.1 spec) + * See: docs/rfc-d1-schema.md `### `swaps`` + */ + +/** + * A single row from swaps mapped to the API response shape. + * Field names mirror the migration (sender, token_in, etc.) — not the + * original #683 spec. Treat these as fixed. + */ +export interface SwapRow { + txid: string; + sender: string; + contract_id: string; + function_name: string; + token_in: string; + amount_in: number; + token_out: string; + amount_out: number; + burn_block_time: number; + tx_status: string; + source: "agent" | "cron" | "chainhook"; + scored_value: number | null; + scored_at: string | null; +} + +/** + * GET /api/competition/status response shape. + * + * When `registered` is false, callers should omit/zero the count fields. + * Unregistered addresses are NOT a 404 — the MCP's `competition_status` + * description tells agents to read `registered: false` and call + * `identity_register`. + */ +export interface CompetitionStatusRow { + address: string; + agent_id: number | null; + registered: boolean; + trade_count: number; + verified_trade_count: number; + first_trade_at: number | null; + last_trade_at: number | null; +} + +interface D1StatusRow { + address: string; + agent_id: number | null; + registered: number; + trade_count: number; + verified_trade_count: number; + first_trade_at: number | null; + last_trade_at: number | null; +} + +interface D1SwapRow { + txid: string; + sender: string; + contract_id: string; + function_name: string; + token_in: string; + amount_in: number; + token_out: string; + amount_out: number; + burn_block_time: number; + tx_status: string; + source: string; + scored_value: number | null; + scored_at: string | null; +} + +function mapStatusRow(stxAddress: string, row: D1StatusRow | null): CompetitionStatusRow { + if (!row) { + return { + address: stxAddress, + agent_id: null, + registered: false, + trade_count: 0, + verified_trade_count: 0, + first_trade_at: null, + last_trade_at: null, + }; + } + return { + address: row.address, + agent_id: row.agent_id ?? null, + registered: row.registered === 1, + trade_count: row.trade_count ?? 0, + verified_trade_count: row.verified_trade_count ?? 0, + first_trade_at: row.first_trade_at ?? null, + last_trade_at: row.last_trade_at ?? null, + }; +} + +function mapSwapRow(row: D1SwapRow): SwapRow { + return { + txid: row.txid, + sender: row.sender, + contract_id: row.contract_id, + function_name: row.function_name, + token_in: row.token_in, + amount_in: row.amount_in, + token_out: row.token_out, + amount_out: row.amount_out, + burn_block_time: row.burn_block_time, + tx_status: row.tx_status, + source: row.source as SwapRow["source"], + scored_value: row.scored_value ?? null, + scored_at: row.scored_at ?? null, + }; +} + +/** + * Fetch the trading-comp status row for a given STX address. + * + * Joins registered_wallets (membership) + agents (agent_id) + swaps (counts). + * Returns a synthesized "unregistered" row when the address is not in + * registered_wallets — do NOT 404 from the route on this case. + * + * SQL shape (locked, per PHASE-3.1-HANDOFF.md): + * SELECT rw.stx_address, a.erc8004_agent_id, 1 AS registered, + * COUNT(s.txid), SUM(... success ...), + * MIN(burn_block_time), MAX(burn_block_time) + * FROM registered_wallets rw + * JOIN agents a ON a.stx_address = rw.stx_address + * LEFT JOIN swaps s ON s.sender = rw.stx_address + * WHERE rw.stx_address = ?1 + * GROUP BY rw.stx_address, a.erc8004_agent_id + */ +export async function getCompetitionStatusFromD1( + db: D1Database, + stxAddress: string +): Promise { + const sql = ` + SELECT + rw.stx_address AS address, + a.erc8004_agent_id AS agent_id, + 1 AS registered, + COUNT(s.txid) AS trade_count, + SUM(CASE WHEN s.tx_status = 'success' THEN 1 ELSE 0 END) AS verified_trade_count, + MIN(s.burn_block_time) AS first_trade_at, + MAX(s.burn_block_time) AS last_trade_at + FROM registered_wallets rw + JOIN agents a ON a.stx_address = rw.stx_address + LEFT JOIN swaps s ON s.sender = rw.stx_address + WHERE rw.stx_address = ?1 + GROUP BY rw.stx_address, a.erc8004_agent_id + `; + + const row = await db.prepare(sql).bind(stxAddress).first(); + return mapStatusRow(stxAddress, row); +} + +/** + * Opaque cursor over (burn_block_time, txid). The wire format is + * base64url(JSON.stringify({ t: burn_block_time, x: txid })). + * + * Decoded form is internal — callers pass the raw cursor string to + * listSwapsFromD1 which decodes once. + */ +export interface SwapsCursor { + t: number; + x: string; +} + +function base64urlEncode(input: string): string { + // Worker runtime has btoa; use it for tree-shake friendliness. + const b64 = btoa(input); + return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function base64urlDecode(input: string): string { + const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - (input.length % 4)); + const b64 = input.replace(/-/g, "+").replace(/_/g, "/") + pad; + return atob(b64); +} + +/** + * Encode a (burn_block_time, txid) pair as an opaque cursor. + * Returns null when the page is the last page (no more rows). + */ +export function encodeSwapsCursor(t: number, x: string): string { + return base64urlEncode(JSON.stringify({ t, x })); +} + +/** + * Decode an opaque cursor back to {t, x}. + * Throws when malformed — the route should catch and return 400. + */ +export function decodeSwapsCursor(cursor: string): SwapsCursor { + const decoded = JSON.parse(base64urlDecode(cursor)); + if ( + typeof decoded !== "object" || + decoded === null || + typeof decoded.t !== "number" || + typeof decoded.x !== "string" + ) { + throw new Error("Invalid cursor shape"); + } + return { t: decoded.t, x: decoded.x }; +} + +/** + * Fetch a page of swaps for an STX sender, newest first. + * + * Pagination is keyset over (burn_block_time DESC, txid DESC) — stable under + * concurrent inserts (unlike OFFSET, which shifts when new rows land between + * pages). The cursor identifies the *last* row of the previous page; rows + * strictly less than that pair are returned. + * + * SQL shape (locked, per PHASE-3.1-HANDOFF.md): + * SELECT … FROM swaps + * WHERE sender = ?1 + * AND (?2 IS NULL OR (burn_block_time, txid) < (?2, ?3)) + * ORDER BY burn_block_time DESC, txid DESC + * LIMIT ?4 + * + * Callers should request `limit + 1` semantics by examining the returned + * length and synthesizing next_cursor only when more rows are likely; here + * we return the page as-is and let the route layer decide. + */ +export async function listSwapsFromD1( + db: D1Database, + stxAddress: string, + limit: number, + cursor: SwapsCursor | null +): Promise { + const sql = ` + SELECT + txid, sender, contract_id, function_name, + token_in, amount_in, token_out, amount_out, + burn_block_time, tx_status, source, scored_value, scored_at + FROM swaps + WHERE sender = ?1 + AND (?2 IS NULL OR (burn_block_time < ?2 OR (burn_block_time = ?2 AND txid < ?3))) + ORDER BY burn_block_time DESC, txid DESC + LIMIT ?4 + `; + + const result = await db + .prepare(sql) + .bind(stxAddress, cursor?.t ?? null, cursor?.x ?? null, limit) + .all(); + + return (result.results ?? []).map(mapSwapRow); +} + +/** + * Count all swaps for an STX sender. Used by the route layer for + * total-count reporting alongside the paginated trades list. + */ +export async function countSwapsFromD1( + db: D1Database, + stxAddress: string +): Promise { + const sql = `SELECT COUNT(*) AS cnt FROM swaps WHERE sender = ?1`; + const row = await db.prepare(sql).bind(stxAddress).first<{ cnt: number }>(); + return row?.cnt ?? 0; +} From 92a6039b99d8dcff74164bccf048cf062de68f14 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:46:21 +0545 Subject: [PATCH 02/56] test(competition): unit tests for d1-reads helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-A — 18 unit tests over getCompetitionStatusFromD1, listSwapsFromD1, countSwapsFromD1, and the cursor codec. Mock-D1 pattern matches lib/inbox/__tests__/d1-reads.test.ts. Key assertions: - status query is a JOIN over registered_wallets + agents + LEFT JOIN swaps with SUM(CASE WHEN tx_status='success') for verified count. - unregistered address (db.first returns null) synthesizes a registered:false row rather than throwing or returning null. - listSwapsFromD1 uses keyset less-than over (burn_block_time, txid) in either tuple form or OR-expansion (both valid SQLite shapes). - cursor codec is base64url-safe and rejects malformed input. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/__tests__/d1-reads.test.ts | 282 +++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 lib/competition/__tests__/d1-reads.test.ts diff --git a/lib/competition/__tests__/d1-reads.test.ts b/lib/competition/__tests__/d1-reads.test.ts new file mode 100644 index 00000000..ef8d85d4 --- /dev/null +++ b/lib/competition/__tests__/d1-reads.test.ts @@ -0,0 +1,282 @@ +/** + * Tests for lib/competition/d1-reads.ts + * + * Phase 3.1 PR-A — read routes only. The verifier ships in PR-B. + * + * Verifies: + * - getCompetitionStatusFromD1: SQL shape, JOIN structure, mapping, + * unregistered-address synthesis (registered: false, do not 404) + * - listSwapsFromD1: keyset pagination over (burn_block_time, txid), + * ORDER BY DESC, LIMIT bindings, row mapping + * - countSwapsFromD1: COUNT(*) WHERE sender shape + * - encodeSwapsCursor / decodeSwapsCursor: round-trip + reject malformed + * + * Mock-D1 pattern matches lib/inbox/__tests__/d1-reads.test.ts. + */ + +import { describe, it, expect, vi } from "vitest"; +import { + getCompetitionStatusFromD1, + listSwapsFromD1, + countSwapsFromD1, + encodeSwapsCursor, + decodeSwapsCursor, +} from "../d1-reads"; + +// ── D1 mock helpers ────────────────────────────────────────────────────────── + +function createPreparedStatement( + rows: T[] = [], + firstResult: T | null = null +) { + const stmt = { + bind: vi.fn(), + run: vi.fn().mockResolvedValue({ meta: { changes: 0 } }), + first: vi.fn().mockResolvedValue(firstResult), + all: vi.fn().mockResolvedValue({ results: rows }), + raw: vi.fn(), + }; + stmt.bind.mockReturnValue(stmt); + return stmt; +} + +function createMockD1( + rows: T[] = [], + firstResult: T | null = null +): { db: D1Database; stmt: ReturnType> } { + const stmt = createPreparedStatement(rows, firstResult); + const db = { + prepare: vi.fn().mockReturnValue(stmt), + batch: vi.fn(), + dump: vi.fn(), + exec: vi.fn(), + } as unknown as D1Database; + return { db, stmt }; +} + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const STX_ADDRESS = "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE"; + +const STATUS_ROW = { + address: STX_ADDRESS, + agent_id: 42, + registered: 1, + trade_count: 12, + verified_trade_count: 10, + first_trade_at: 1762547890, + last_trade_at: 1762634290, +}; + +const SWAP_ROW = { + txid: "0x46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4", + sender: STX_ADDRESS, + contract_id: "SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M.stableswap-stx-ststx-v-1-2", + function_name: "swap-x-for-y", + token_in: "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.wstx", + amount_in: 1000000, + token_out: "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token", + amount_out: 859839, + burn_block_time: 1762547890, + tx_status: "success", + source: "agent", + scored_value: null, + scored_at: null, +}; + +// ── getCompetitionStatusFromD1 ─────────────────────────────────────────────── + +describe("getCompetitionStatusFromD1", () => { + it("issues a JOIN over registered_wallets + agents + swaps with sender filter", async () => { + const { db, stmt } = createMockD1([], STATUS_ROW); + await getCompetitionStatusFromD1(db, STX_ADDRESS); + + const sql: string = (db.prepare as ReturnType).mock.calls[0][0]; + expect(sql).toContain("FROM registered_wallets rw"); + expect(sql).toContain("JOIN agents a ON a.stx_address = rw.stx_address"); + expect(sql).toContain("LEFT JOIN swaps s ON s.sender = rw.stx_address"); + expect(sql).toContain("WHERE rw.stx_address = ?1"); + expect(sql).toContain("GROUP BY"); + + expect(stmt.bind.mock.calls[0][0]).toBe(STX_ADDRESS); + }); + + it("counts success-status trades into verified_trade_count via SUM CASE", async () => { + const { db } = createMockD1([], STATUS_ROW); + await getCompetitionStatusFromD1(db, STX_ADDRESS); + const sql: string = (db.prepare as ReturnType).mock.calls[0][0]; + expect(sql).toContain("SUM(CASE WHEN s.tx_status = 'success' THEN 1 ELSE 0 END)"); + }); + + it("maps a populated row to CompetitionStatusRow with registered=true", async () => { + const { db } = createMockD1([], STATUS_ROW); + const result = await getCompetitionStatusFromD1(db, STX_ADDRESS); + + expect(result).toEqual({ + address: STX_ADDRESS, + agent_id: 42, + registered: true, + trade_count: 12, + verified_trade_count: 10, + first_trade_at: 1762547890, + last_trade_at: 1762634290, + }); + }); + + it("returns registered=false synthesized row when address is not in registered_wallets (no 404)", async () => { + const { db } = createMockD1([], null); + const result = await getCompetitionStatusFromD1(db, STX_ADDRESS); + + expect(result).toEqual({ + address: STX_ADDRESS, + agent_id: null, + registered: false, + trade_count: 0, + verified_trade_count: 0, + first_trade_at: null, + last_trade_at: null, + }); + }); + + it("preserves null agent_id when the agent has not minted an ERC-8004 identity", async () => { + const noIdentityRow = { ...STATUS_ROW, agent_id: null }; + const { db } = createMockD1([], noIdentityRow); + const result = await getCompetitionStatusFromD1(db, STX_ADDRESS); + expect(result.agent_id).toBeNull(); + expect(result.registered).toBe(true); + }); + + it("preserves null first/last trade times when the agent has zero swaps", async () => { + const noTradesRow = { + ...STATUS_ROW, + trade_count: 0, + verified_trade_count: 0, + first_trade_at: null, + last_trade_at: null, + }; + const { db } = createMockD1([], noTradesRow); + const result = await getCompetitionStatusFromD1(db, STX_ADDRESS); + expect(result.trade_count).toBe(0); + expect(result.first_trade_at).toBeNull(); + expect(result.last_trade_at).toBeNull(); + }); +}); + +// ── listSwapsFromD1 ────────────────────────────────────────────────────────── + +describe("listSwapsFromD1", () => { + it("issues SELECT from swaps WHERE sender = ?1 ordered by burn_block_time DESC, txid DESC", async () => { + const { db } = createMockD1([SWAP_ROW]); + await listSwapsFromD1(db, STX_ADDRESS, 50, null); + + const sql: string = (db.prepare as ReturnType).mock.calls[0][0]; + expect(sql).toContain("FROM swaps"); + expect(sql).toContain("WHERE sender = ?1"); + expect(sql).toContain("ORDER BY burn_block_time DESC, txid DESC"); + expect(sql).toContain("LIMIT ?4"); + }); + + it("binds (sender, null, null, limit) when no cursor is provided", async () => { + const { db, stmt } = createMockD1([]); + await listSwapsFromD1(db, STX_ADDRESS, 50, null); + + expect(stmt.bind).toHaveBeenCalledWith(STX_ADDRESS, null, null, 50); + }); + + it("binds (sender, cursor.t, cursor.x, limit) when a cursor is provided", async () => { + const { db, stmt } = createMockD1([]); + await listSwapsFromD1(db, STX_ADDRESS, 25, { t: 1762547890, x: "0xabc" }); + + expect(stmt.bind).toHaveBeenCalledWith(STX_ADDRESS, 1762547890, "0xabc", 25); + }); + + it("uses keyset semantics so the cursor pair is strictly less-than (no duplicate row on repeat)", async () => { + const { db } = createMockD1([]); + await listSwapsFromD1(db, STX_ADDRESS, 50, null); + const sql: string = (db.prepare as ReturnType).mock.calls[0][0]; + // The lexicographic-less-than predicate; either explicit tuple form or + // the equivalent OR-expansion must be present. + const hasTupleForm = + sql.includes("(burn_block_time, txid) < (?2, ?3)") || + (sql.includes("burn_block_time < ?2") && + sql.includes("burn_block_time = ?2 AND txid < ?3")); + expect(hasTupleForm).toBe(true); + }); + + it("maps D1 swap rows to SwapRow shape", async () => { + const { db } = createMockD1([SWAP_ROW]); + const rows = await listSwapsFromD1(db, STX_ADDRESS, 50, null); + + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ + txid: SWAP_ROW.txid, + sender: SWAP_ROW.sender, + contract_id: SWAP_ROW.contract_id, + function_name: SWAP_ROW.function_name, + token_in: SWAP_ROW.token_in, + amount_in: SWAP_ROW.amount_in, + token_out: SWAP_ROW.token_out, + amount_out: SWAP_ROW.amount_out, + burn_block_time: SWAP_ROW.burn_block_time, + tx_status: SWAP_ROW.tx_status, + source: "agent", + scored_value: null, + scored_at: null, + }); + }); + + it("returns [] when no rows match", async () => { + const { db } = createMockD1([]); + const rows = await listSwapsFromD1(db, STX_ADDRESS, 50, null); + expect(rows).toHaveLength(0); + }); +}); + +// ── countSwapsFromD1 ───────────────────────────────────────────────────────── + +describe("countSwapsFromD1", () => { + it("issues SELECT COUNT(*) FROM swaps WHERE sender = ?1", async () => { + const { db, stmt } = createMockD1([], { cnt: 7 }); + const count = await countSwapsFromD1(db, STX_ADDRESS); + + expect(count).toBe(7); + const sql: string = (db.prepare as ReturnType).mock.calls[0][0]; + expect(sql).toContain("SELECT COUNT(*)"); + expect(sql).toContain("FROM swaps"); + expect(sql).toContain("WHERE sender = ?1"); + expect(stmt.bind.mock.calls[0][0]).toBe(STX_ADDRESS); + }); + + it("returns 0 when first() returns null", async () => { + const { db } = createMockD1([], null); + const count = await countSwapsFromD1(db, STX_ADDRESS); + expect(count).toBe(0); + }); +}); + +// ── cursor codec ───────────────────────────────────────────────────────────── + +describe("encodeSwapsCursor / decodeSwapsCursor", () => { + it("round-trips a (t, x) pair", () => { + const cursor = encodeSwapsCursor(1762547890, "0xabcdef"); + const decoded = decodeSwapsCursor(cursor); + expect(decoded).toEqual({ t: 1762547890, x: "0xabcdef" }); + }); + + it("produces a base64url-safe string (no +, /, =)", () => { + const cursor = encodeSwapsCursor(1762547890, "0xabcdef"); + expect(cursor).not.toMatch(/[+/=]/); + }); + + it("throws when the cursor is not base64", () => { + expect(() => decodeSwapsCursor("!!!not-base64!!!")).toThrow(); + }); + + it("throws when the decoded payload has the wrong shape", () => { + const bad = btoa(JSON.stringify({ foo: "bar" })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + expect(() => decodeSwapsCursor(bad)).toThrow(); + }); +}); From 3538335b88428a4bf7419f4f83837e4be6e6dc95 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:46:31 +0545 Subject: [PATCH 03/56] feat(competition): add GET /api/competition/status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-A — read-only status surface for the trading competition. Returns membership + verified trade counts for a single STX address. Locked contract: - Unregistered addresses return { registered: false } (not 404). The MCP's competition_status tool description tells agents to read this field and route them through identity_register rather than treating it as an error. - agent_id is null when the address has not minted an ERC-8004 NFT. - first_trade_at / last_trade_at are unix seconds. Implementation: - ?docs=1 returns self-doc payload without touching D1 (mirrors /api/agents and /api/dashboard). - RATE_LIMIT_READ binding (300/min per IP) keyed by comp-status:{ip}. Fails closed in production/preview, open in local dev (per shouldFailClosed semantics). - Validates STX address shape via isStxAddress before any DB work. - D1 binding missing OR D1 throw → 503 with Retry-After:5 + structured body (matches the Cycle 26 fallback contract from PR #722). CACHE_INVARIANTS:POSTURE=public-only-get — no auth-branched cache key. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/competition/status/route.ts | 129 ++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 app/api/competition/status/route.ts diff --git a/app/api/competition/status/route.ts b/app/api/competition/status/route.ts new file mode 100644 index 00000000..d6b58dcb --- /dev/null +++ b/app/api/competition/status/route.ts @@ -0,0 +1,129 @@ +// CACHE_INVARIANTS:POSTURE=public-only-get +// See lib/inbox/CACHE_INVARIANTS.md — GET handler is fully public. +// Read-only status surface; no auth, no per-caller branching. + +import { NextRequest, NextResponse } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { createLogger, createConsoleLogger, isLogsRPC } from "@/lib/logging"; +import { isStxAddress } from "@/lib/validation/address"; +import { shouldFailClosed } from "@/lib/env"; +import { getCompetitionStatusFromD1 } from "@/lib/competition/d1-reads"; + +/** Retry-After value (seconds) to return on 429s — matches the 60s binding window. */ +const RATE_LIMIT_RETRY_AFTER = 60; + +function selfDocResponse() { + return NextResponse.json( + { + endpoint: "/api/competition/status", + method: "GET", + description: + "Trading-comp status for a single STX address. Returns membership + verified trade counts. Unregistered addresses return { registered: false } (not 404) so callers can route to identity_register.", + queryParameters: { + docs: { + type: "string", + description: "Pass ?docs=1 to return this documentation payload instead of data", + example: "?docs=1", + }, + address: { + type: "string", + required: true, + description: "Stacks mainnet address (SP… / SM…) to look up", + example: "?address=SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", + }, + }, + responseFormat: { + address: "string (STX address)", + agent_id: "number | null (ERC-8004 identity NFT id; null until minted)", + registered: "boolean (is the address a registered AIBTC agent)", + trade_count: "number (total swaps recorded for this sender)", + verified_trade_count: "number (swaps with tx_status='success')", + first_trade_at: "number | null (unix seconds of earliest swap)", + last_trade_at: "number | null (unix seconds of latest swap)", + }, + relatedEndpoints: { + trades: "GET /api/competition/trades?address={stx} — paginated trade history", + submit: "POST /api/competition/trades — verify a swap by txid (ships in Phase 3.1 PR-B)", + identity: "GET /api/agents/{address} — agent profile", + }, + documentation: { + openApiSpec: "https://aibtc.com/api/openapi.json", + fullDocs: "https://aibtc.com/llms-full.txt", + agentCard: "https://aibtc.com/.well-known/agent.json", + }, + }, + { headers: { "Cache-Control": "public, max-age=3600, s-maxage=86400" } } + ); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + if (searchParams.get("docs") === "1") { + return selfDocResponse(); + } + + const { env, ctx } = await getCloudflareContext(); + const rayId = request.headers.get("cf-ray") || crypto.randomUUID(); + const logger = isLogsRPC(env.LOGS) + ? createLogger(env.LOGS, ctx, { rayId, path: request.nextUrl.pathname }) + : createConsoleLogger({ rayId, path: request.nextUrl.pathname }); + + const address = searchParams.get("address"); + if (!address || !isStxAddress(address)) { + return NextResponse.json( + { + error: "Missing or invalid `address` query param. Expected a Stacks mainnet address (SP… / SM…).", + example: "/api/competition/status?address=SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", + }, + { status: 400 } + ); + } + + // IP-keyed read rate limit (300/min — see wrangler.jsonc RATE_LIMIT_READ). + const ip = request.headers.get("cf-connecting-ip") || request.headers.get("x-forwarded-for") || "unknown"; + let ipLimited = false; + try { + const result = await env.RATE_LIMIT_READ.limit({ key: `comp-status:${ip}` }); + ipLimited = !result.success; + } catch (err) { + const failClosed = shouldFailClosed(env); + logger.warn("Rate limit binding error", { error: String(err), failClosed }); + if (failClosed) ipLimited = true; + } + if (ipLimited) { + return NextResponse.json( + { error: "Too many requests from this IP. Slow down.", retryAfter: RATE_LIMIT_RETRY_AFTER }, + { status: 429, headers: { "Retry-After": String(RATE_LIMIT_RETRY_AFTER) } } + ); + } + + const db = env.DB as D1Database | undefined; + if (!db) { + logger.warn("D1 binding missing on competition/status"); + return NextResponse.json( + { + error: "transient_d1_unavailable", + message: "Competition database temporarily unavailable. Please retry shortly.", + retry_after: 5, + }, + { status: 503, headers: { "Retry-After": "5" } } + ); + } + + try { + const status = await getCompetitionStatusFromD1(db, address); + return NextResponse.json(status, { + headers: { "Cache-Control": "public, max-age=10, s-maxage=10" }, + }); + } catch (err) { + logger.warn("D1 read failed on competition/status", { error: String(err), address }); + return NextResponse.json( + { + error: "transient_d1_unavailable", + message: "Competition database temporarily unavailable. Please retry shortly.", + retry_after: 5, + }, + { status: 503, headers: { "Retry-After": "5" } } + ); + } +} From 9ee623538d47765a42943b8a0593bd8a950a6c0c Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:46:38 +0545 Subject: [PATCH 04/56] feat(competition): add GET /api/competition/trades + POST 501 stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-A — paginated swap history (read-only). POST is reserved on the route so callers can discover the contract early; the verifier worker that handles POST ships in the next phase. GET behaviour: - Keyset pagination over (burn_block_time, txid). Request limit+1 rows internally so we can detect "more available" and synthesize a next_cursor from the last row of the page. Returned page is truncated to the user's requested limit. - Limit clamped to [1, 200], default 50. - Cursor is opaque base64url; malformed cursors return 400, not 500. - 503 + Retry-After:5 on D1 throw or missing binding. POST behaviour (stub): - Returns 501 not_implemented with a message pointing at PR-B. Chose 501 over 405 so callers know the method is allocated (not "method not supported on this route") but not yet wired. OpenAPI advertises the request shape so MCP clients can build the call ahead of time. CACHE_INVARIANTS:POSTURE=public-only-get. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/competition/trades/route.ts | 218 ++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 app/api/competition/trades/route.ts diff --git a/app/api/competition/trades/route.ts b/app/api/competition/trades/route.ts new file mode 100644 index 00000000..99248816 --- /dev/null +++ b/app/api/competition/trades/route.ts @@ -0,0 +1,218 @@ +// CACHE_INVARIANTS:POSTURE=public-only-get +// See lib/inbox/CACHE_INVARIANTS.md — GET handler is fully public. +// POST stub returns 501 until PR-B (verifier worker) ships. + +import { NextRequest, NextResponse } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { createLogger, createConsoleLogger, isLogsRPC } from "@/lib/logging"; +import { isStxAddress } from "@/lib/validation/address"; +import { shouldFailClosed } from "@/lib/env"; +import { + listSwapsFromD1, + countSwapsFromD1, + encodeSwapsCursor, + decodeSwapsCursor, +} from "@/lib/competition/d1-reads"; + +const RATE_LIMIT_RETRY_AFTER = 60; + +const DEFAULT_LIMIT = 50; +const MIN_LIMIT = 1; +const MAX_LIMIT = 200; + +function selfDocResponse() { + return NextResponse.json( + { + endpoint: "/api/competition/trades", + methods: ["GET", "POST"], + description: + "Trading-comp swap history. GET returns paginated trades; POST verifies a swap by txid (ships in Phase 3.1 PR-B; currently 501 Not Implemented).", + get: { + queryParameters: { + docs: { + type: "string", + description: "Pass ?docs=1 to return this documentation payload instead of data", + example: "?docs=1", + }, + address: { + type: "string", + required: true, + description: "Stacks mainnet address (SP… / SM…) to look up", + example: "?address=SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", + }, + limit: { + type: "number", + description: `Page size, ${MIN_LIMIT}-${MAX_LIMIT}, default ${DEFAULT_LIMIT}`, + example: "?limit=100", + }, + cursor: { + type: "string", + description: "Opaque base64url cursor returned in `next_cursor`. Omit on first page.", + example: "?cursor=eyJ0IjoxNzYyNTQ3ODkwLCJ4IjoiMHhhYmNkZWYifQ", + }, + }, + responseFormat: { + trades: [ + { + txid: "string (Stacks tx hash, 0x-prefixed)", + sender: "string (STX address)", + contract_id: "string (e.g. SP….stableswap-stx-ststx-v-1-2)", + function_name: "string (e.g. swap-x-for-y)", + token_in: "string (input asset contract id)", + amount_in: "number (raw on-chain units)", + token_out: "string (output asset contract id)", + amount_out: "number (raw on-chain units)", + burn_block_time: "number (unix seconds)", + tx_status: "string (success | abort_by_response | …)", + source: "string ('agent' | 'cron' | 'chainhook')", + scored_value: "number | null", + scored_at: "string | null (ISO-8601)", + }, + ], + next_cursor: "string | null (pass back as ?cursor= for the next page)", + }, + }, + post: { + status: "501 Not Implemented", + shipsIn: "Phase 3.1 PR-B", + description: + "Will accept { txid } and verify against Hiro + insert into swaps. Currently a placeholder so the route is reservation-stable.", + }, + relatedEndpoints: { + status: "GET /api/competition/status?address={stx} — membership + counts", + }, + documentation: { + openApiSpec: "https://aibtc.com/api/openapi.json", + fullDocs: "https://aibtc.com/llms-full.txt", + agentCard: "https://aibtc.com/.well-known/agent.json", + }, + }, + { headers: { "Cache-Control": "public, max-age=3600, s-maxage=86400" } } + ); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + if (searchParams.get("docs") === "1") { + return selfDocResponse(); + } + + const { env, ctx } = await getCloudflareContext(); + const rayId = request.headers.get("cf-ray") || crypto.randomUUID(); + const logger = isLogsRPC(env.LOGS) + ? createLogger(env.LOGS, ctx, { rayId, path: request.nextUrl.pathname }) + : createConsoleLogger({ rayId, path: request.nextUrl.pathname }); + + const address = searchParams.get("address"); + if (!address || !isStxAddress(address)) { + return NextResponse.json( + { + error: "Missing or invalid `address` query param. Expected a Stacks mainnet address (SP… / SM…).", + example: "/api/competition/trades?address=SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", + }, + { status: 400 } + ); + } + + const limitParam = searchParams.get("limit"); + let limit = DEFAULT_LIMIT; + if (limitParam) { + const parsed = parseInt(limitParam, 10); + if (!Number.isFinite(parsed)) { + return NextResponse.json( + { error: `Invalid limit. Expected integer in [${MIN_LIMIT}, ${MAX_LIMIT}].` }, + { status: 400 } + ); + } + limit = Math.min(Math.max(parsed, MIN_LIMIT), MAX_LIMIT); + } + + const cursorParam = searchParams.get("cursor"); + let cursor: { t: number; x: string } | null = null; + if (cursorParam) { + try { + cursor = decodeSwapsCursor(cursorParam); + } catch { + return NextResponse.json( + { error: "Invalid cursor. Pass the opaque value from a previous `next_cursor` response." }, + { status: 400 } + ); + } + } + + // IP-keyed read rate limit (300/min). + const ip = request.headers.get("cf-connecting-ip") || request.headers.get("x-forwarded-for") || "unknown"; + let ipLimited = false; + try { + const result = await env.RATE_LIMIT_READ.limit({ key: `comp-trades:${ip}` }); + ipLimited = !result.success; + } catch (err) { + const failClosed = shouldFailClosed(env); + logger.warn("Rate limit binding error", { error: String(err), failClosed }); + if (failClosed) ipLimited = true; + } + if (ipLimited) { + return NextResponse.json( + { error: "Too many requests from this IP. Slow down.", retryAfter: RATE_LIMIT_RETRY_AFTER }, + { status: 429, headers: { "Retry-After": String(RATE_LIMIT_RETRY_AFTER) } } + ); + } + + const db = env.DB as D1Database | undefined; + if (!db) { + logger.warn("D1 binding missing on competition/trades"); + return NextResponse.json( + { + error: "transient_d1_unavailable", + message: "Competition database temporarily unavailable. Please retry shortly.", + retry_after: 5, + }, + { status: 503, headers: { "Retry-After": "5" } } + ); + } + + // Request one extra row beyond the page to detect whether more exist. + // If the DB returns exactly limit+1, we have a next page; drop the extra + // and synthesize a cursor from the *last* row of the returned page. + let trades; + try { + trades = await listSwapsFromD1(db, address, limit + 1, cursor); + } catch (err) { + logger.warn("D1 read failed on competition/trades", { error: String(err), address }); + return NextResponse.json( + { + error: "transient_d1_unavailable", + message: "Competition database temporarily unavailable. Please retry shortly.", + retry_after: 5, + }, + { status: 503, headers: { "Retry-After": "5" } } + ); + } + + let nextCursor: string | null = null; + if (trades.length > limit) { + trades = trades.slice(0, limit); + const last = trades[trades.length - 1]; + nextCursor = encodeSwapsCursor(last.burn_block_time, last.txid); + } + + return NextResponse.json( + { trades, next_cursor: nextCursor }, + { headers: { "Cache-Control": "public, max-age=10, s-maxage=10" } } + ); +} + +export async function POST() { + // Phase 3.1 PR-A only ships read routes. The verifier worker — Hiro fetch, + // allowlist check, INSERT OR IGNORE — lands in PR-B (#734). Returning 501 + // (not 405) so callers know the *method* is allocated but not yet wired. + return NextResponse.json( + { + error: "not_implemented", + message: + "POST /api/competition/trades ships in Phase 3.1 PR-B. The route is reserved; the verifier worker is not yet wired.", + docs: "/api/competition/trades?docs=1", + }, + { status: 501 } + ); +} From 64e6f0e0d5c52926e79aea96aac0d2968a94e9db Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:46:46 +0545 Subject: [PATCH 05/56] test(competition): D1-throws fallback regression for read routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-A — locks the 503-on-D1-throw contract for both competition read routes, plus exercises the 400/501 paths. When the D1 read layer throws — transient unavailability, network error, schema mismatch — the GET handlers MUST return 503 with a structured body + Retry-After:5, not an unstructured 500. This is the contract that PR #722 established for inbox/outbox; the same guarantee now extends to /api/competition/status and /api/competition/trades. 13 tests cover: - status + trades each return 503 (not 500) when their d1-reads helper rejects. - both return 503 when the D1 binding is missing entirely. - POST trades returns 501 not_implemented with a PR-B pointer. - 400 on missing/malformed address (both routes). - 400 on malformed cursor (decoder throw is caught at the route). - ?docs=1 returns the self-doc payload without invoking any D1 read. Mirrors app/api/inbox/[address]/__tests__/d1-throws-fallback.test.ts. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/d1-throws-fallback.test.ts | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 app/api/competition/__tests__/d1-throws-fallback.test.ts diff --git a/app/api/competition/__tests__/d1-throws-fallback.test.ts b/app/api/competition/__tests__/d1-throws-fallback.test.ts new file mode 100644 index 00000000..7ae93dc6 --- /dev/null +++ b/app/api/competition/__tests__/d1-throws-fallback.test.ts @@ -0,0 +1,224 @@ +/** + * Phase 3.1 PR-A — D1-throws fallback policy regression test. + * + * Mirrors the contract established for inbox/outbox in Phase 2.5 (#722): + * when the D1 read layer throws — transient unavailability, network error, + * schema mismatch — the GET handler MUST return 503 with a structured body + * + Retry-After: 5 header, never an unstructured 500. + * + * Covers both competition read routes: + * - GET /api/competition/status → getCompetitionStatusFromD1 throw + * - GET /api/competition/trades → listSwapsFromD1 throw + * + * See: app/api/inbox/[address]/__tests__/d1-throws-fallback.test.ts (template) + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { NextRequest } from "next/server"; + +// ---- module mocks (must be declared before route imports) ------------------- + +vi.mock("@opennextjs/cloudflare", () => ({ + getCloudflareContext: vi.fn(), +})); + +vi.mock("@/lib/logging", () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + createConsoleLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + isLogsRPC: () => false, +})); + +vi.mock("@/lib/competition/d1-reads", () => ({ + getCompetitionStatusFromD1: vi.fn(), + listSwapsFromD1: vi.fn(), + countSwapsFromD1: vi.fn(), + encodeSwapsCursor: vi.fn((t: number, x: string) => `enc(${t},${x})`), + decodeSwapsCursor: vi.fn(), +})); + +// ---- imports after mocks ---------------------------------------------------- + +import { GET as statusGet } from "../status/route"; +import { GET as tradesGet, POST as tradesPost } from "../trades/route"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { + getCompetitionStatusFromD1, + listSwapsFromD1, +} from "@/lib/competition/d1-reads"; + +// ---- shared fixtures -------------------------------------------------------- + +const TEST_STX = "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE"; + +function buildStatusRequest(): NextRequest { + return new NextRequest(`https://aibtc.com/api/competition/status?address=${TEST_STX}`, { + method: "GET", + }); +} + +function buildTradesRequest(): NextRequest { + return new NextRequest(`https://aibtc.com/api/competition/trades?address=${TEST_STX}`, { + method: "GET", + }); +} + +function mockRateLimit(allow = true) { + return { + limit: vi.fn().mockResolvedValue({ success: allow }), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + + (getCloudflareContext as Mock).mockReturnValue({ + env: { + DB: { prepare: vi.fn() } as unknown as D1Database, + RATE_LIMIT_READ: mockRateLimit(true), + LOGS: undefined, + }, + ctx: { waitUntil: vi.fn() }, + }); +}); + +describe("Phase 3.1 PR-A — D1-throws fallback policy (status)", () => { + it("returns 503 with structured body when getCompetitionStatusFromD1 throws", async () => { + (getCompetitionStatusFromD1 as Mock).mockRejectedValue( + new Error("D1_ERROR: connection reset") + ); + + const res = await statusGet(buildStatusRequest()); + + expect(res.status).toBe(503); + const body = await res.json(); + expect(body).toMatchObject({ + error: "transient_d1_unavailable", + retry_after: 5, + }); + expect(body.message).toMatch(/temporarily unavailable/i); + expect(res.headers.get("Retry-After")).toBe("5"); + }); + + it("returns 503 (not 500) when D1 throws — guards the Forge cutover pattern", async () => { + (getCompetitionStatusFromD1 as Mock).mockRejectedValue(new Error("D1_ERROR: schema mismatch")); + const res = await statusGet(buildStatusRequest()); + expect(res.status).not.toBe(500); + expect(res.status).toBe(503); + }); + + it("returns 503 when the D1 binding is missing entirely", async () => { + (getCloudflareContext as Mock).mockReturnValue({ + env: { DB: undefined, RATE_LIMIT_READ: mockRateLimit(true), LOGS: undefined }, + ctx: { waitUntil: vi.fn() }, + }); + + const res = await statusGet(buildStatusRequest()); + expect(res.status).toBe(503); + expect(res.headers.get("Retry-After")).toBe("5"); + }); +}); + +describe("Phase 3.1 PR-A — D1-throws fallback policy (trades)", () => { + it("returns 503 with structured body when listSwapsFromD1 throws", async () => { + (listSwapsFromD1 as Mock).mockRejectedValue(new Error("D1_ERROR: connection reset")); + + const res = await tradesGet(buildTradesRequest()); + + expect(res.status).toBe(503); + const body = await res.json(); + expect(body).toMatchObject({ + error: "transient_d1_unavailable", + retry_after: 5, + }); + expect(res.headers.get("Retry-After")).toBe("5"); + }); + + it("returns 503 (not 500) when D1 throws", async () => { + (listSwapsFromD1 as Mock).mockRejectedValue(new Error("D1_ERROR: anything")); + const res = await tradesGet(buildTradesRequest()); + expect(res.status).not.toBe(500); + expect(res.status).toBe(503); + }); + + it("returns 503 when the D1 binding is missing entirely", async () => { + (getCloudflareContext as Mock).mockReturnValue({ + env: { DB: undefined, RATE_LIMIT_READ: mockRateLimit(true), LOGS: undefined }, + ctx: { waitUntil: vi.fn() }, + }); + + const res = await tradesGet(buildTradesRequest()); + expect(res.status).toBe(503); + expect(res.headers.get("Retry-After")).toBe("5"); + }); +}); + +describe("Phase 3.1 PR-A — POST /api/competition/trades is reserved (501)", () => { + it("returns 501 with a not_implemented body so callers know the verifier ships in PR-B", async () => { + const res = await tradesPost(); + expect(res.status).toBe(501); + const body = await res.json(); + expect(body).toMatchObject({ error: "not_implemented" }); + expect(body.message).toMatch(/PR-B/); + }); +}); + +describe("Phase 3.1 PR-A — input validation (400)", () => { + it("status returns 400 on missing address", async () => { + const res = await statusGet( + new NextRequest("https://aibtc.com/api/competition/status", { method: "GET" }) + ); + expect(res.status).toBe(400); + }); + + it("status returns 400 on malformed address", async () => { + const res = await statusGet( + new NextRequest("https://aibtc.com/api/competition/status?address=not-an-stx", { + method: "GET", + }) + ); + expect(res.status).toBe(400); + }); + + it("trades returns 400 on missing address", async () => { + const res = await tradesGet( + new NextRequest("https://aibtc.com/api/competition/trades", { method: "GET" }) + ); + expect(res.status).toBe(400); + }); + + it("trades returns 400 when cursor is malformed", async () => { + const { decodeSwapsCursor } = await import("@/lib/competition/d1-reads"); + (decodeSwapsCursor as Mock).mockImplementation(() => { + throw new Error("bad cursor"); + }); + const res = await tradesGet( + new NextRequest( + `https://aibtc.com/api/competition/trades?address=${TEST_STX}&cursor=garbage`, + { method: "GET" } + ) + ); + expect(res.status).toBe(400); + }); +}); + +describe("Phase 3.1 PR-A — self-doc (?docs=1)", () => { + it("status returns the doc payload (200) without touching D1", async () => { + const res = await statusGet( + new NextRequest("https://aibtc.com/api/competition/status?docs=1", { method: "GET" }) + ); + expect(res.status).toBe(200); + expect(getCompetitionStatusFromD1).not.toHaveBeenCalled(); + const body = await res.json(); + expect(body.endpoint).toBe("/api/competition/status"); + }); + + it("trades returns the doc payload (200) without touching D1", async () => { + const res = await tradesGet( + new NextRequest("https://aibtc.com/api/competition/trades?docs=1", { method: "GET" }) + ); + expect(res.status).toBe(200); + expect(listSwapsFromD1).not.toHaveBeenCalled(); + const body = await res.json(); + expect(body.endpoint).toBe("/api/competition/trades"); + }); +}); From 42a682c93dcf5ccea8b8bcc198ce707b4a459fe4 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:46:57 +0545 Subject: [PATCH 06/56] docs(openapi): publish /api/competition/{status,trades} schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-A — adds OpenAPI 3.1 path objects for the two new competition read routes plus the reserved POST verifier stub. - /api/competition/status (GET): documents the registered:false contract for non-member addresses so MCP clients don't treat it as a 404 condition. - /api/competition/trades (GET): documents keyset pagination with opaque cursor, the limit window (1-200), and the full SwapRow shape including the 'agent' | 'cron' | 'chainhook' source enum and the (currently null) scored_value / scored_at fields. - /api/competition/trades (POST): advertised as 501 with a pointer to Phase 3.1 PR-B so MCP can wire the call ahead of time. All three reference the shared ErrorResponse component for 4xx/503 paths. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/openapi.json/route.ts | 227 ++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/app/api/openapi.json/route.ts b/app/api/openapi.json/route.ts index 99c34370..f1279597 100644 --- a/app/api/openapi.json/route.ts +++ b/app/api/openapi.json/route.ts @@ -1032,6 +1032,233 @@ export function GET() { }, }, }, + "/api/competition/status": { + get: { + operationId: "getCompetitionStatus", + summary: "Trading-comp status for a single STX address", + description: + "Returns membership + verified trade counts for the given STX address. " + + "Unregistered addresses return `{ registered: false }` (not 404) so callers " + + "can route to `identity_register` instead of treating it as an error. " + + "Pass `?docs=1` to receive a self-documenting payload.", + parameters: [ + { + name: "address", + in: "query", + required: true, + description: "Stacks mainnet address (SP… / SM…)", + schema: { type: "string", pattern: "^S[MP][0-9A-Z]{38,40}$" }, + }, + { + name: "docs", + in: "query", + required: false, + description: "Pass `1` to return the self-documenting payload instead of data", + schema: { type: "string", enum: ["1"] }, + }, + ], + responses: { + "200": { + description: "Competition status row", + content: { + "application/json": { + schema: { + type: "object", + required: [ + "address", + "agent_id", + "registered", + "trade_count", + "verified_trade_count", + "first_trade_at", + "last_trade_at", + ], + properties: { + address: { type: "string" }, + agent_id: { type: ["integer", "null"] }, + registered: { type: "boolean" }, + trade_count: { type: "integer", minimum: 0 }, + verified_trade_count: { type: "integer", minimum: 0 }, + first_trade_at: { type: ["integer", "null"], description: "Unix seconds" }, + last_trade_at: { type: ["integer", "null"], description: "Unix seconds" }, + }, + }, + }, + }, + }, + "400": { + description: "Missing or malformed `address` parameter", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + "429": { + description: "Rate limited (per-IP read bucket)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + "503": { + description: "D1 temporarily unavailable — retry per `Retry-After` header", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + }, + }, + }, + "/api/competition/trades": { + get: { + operationId: "listCompetitionTrades", + summary: "Paginated trade history for an STX address", + description: + "Returns swaps for the given sender, newest first, with keyset pagination over " + + "(burn_block_time, txid). The cursor is opaque base64url; pass back the value " + + "returned in `next_cursor` to fetch the next page. Limit is 1–200, default 50.", + parameters: [ + { + name: "address", + in: "query", + required: true, + description: "Stacks mainnet address (SP… / SM…)", + schema: { type: "string", pattern: "^S[MP][0-9A-Z]{38,40}$" }, + }, + { + name: "limit", + in: "query", + required: false, + description: "Page size (1–200, default 50)", + schema: { type: "integer", minimum: 1, maximum: 200, default: 50 }, + }, + { + name: "cursor", + in: "query", + required: false, + description: "Opaque cursor from a previous response's `next_cursor`. Omit on first page.", + schema: { type: "string" }, + }, + { + name: "docs", + in: "query", + required: false, + description: "Pass `1` to return the self-documenting payload", + schema: { type: "string", enum: ["1"] }, + }, + ], + responses: { + "200": { + description: "Page of trades plus next_cursor", + content: { + "application/json": { + schema: { + type: "object", + required: ["trades", "next_cursor"], + properties: { + trades: { + type: "array", + items: { + type: "object", + required: [ + "txid", + "sender", + "contract_id", + "function_name", + "token_in", + "amount_in", + "token_out", + "amount_out", + "burn_block_time", + "tx_status", + "source", + ], + properties: { + txid: { type: "string" }, + sender: { type: "string" }, + contract_id: { type: "string" }, + function_name: { type: "string" }, + token_in: { type: "string" }, + amount_in: { type: "integer" }, + token_out: { type: "string" }, + amount_out: { type: "integer" }, + burn_block_time: { type: "integer", description: "Unix seconds" }, + tx_status: { type: "string" }, + source: { type: "string", enum: ["agent", "cron", "chainhook"] }, + scored_value: { type: ["integer", "null"] }, + scored_at: { type: ["string", "null"] }, + }, + }, + }, + next_cursor: { type: ["string", "null"] }, + }, + }, + }, + }, + }, + "400": { + description: "Missing/malformed `address`, invalid `limit`, or malformed `cursor`", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + "429": { + description: "Rate limited (per-IP read bucket)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + "503": { + description: "D1 temporarily unavailable — retry per `Retry-After` header", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + }, + }, + post: { + operationId: "submitCompetitionTrade", + summary: "Submit a swap txid for verification (reserved — ships in Phase 3.1 PR-B)", + description: + "The verifier worker — Hiro fetch, allowlist check, INSERT OR IGNORE — ships in " + + "Phase 3.1 PR-B (issue #734). The method is reserved on this route so callers can " + + "discover the contract early; until PR-B lands this endpoint returns 501.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["txid"], + properties: { + txid: { type: "string", description: "Stacks tx hash (0x-prefixed)" }, + }, + }, + }, + }, + }, + responses: { + "501": { + description: "Not yet implemented — ships in Phase 3.1 PR-B", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + }, + }, + }, "/api/levels": { get: { operationId: "getLevelSystem", From 76c67f140d0806f06eb2a709cb9a150a67db2cd0 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:47:03 +0545 Subject: [PATCH 07/56] docs(agent-card): advertise trading-comp skill in A2A agent card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-A — adds a 'trading-comp' skill entry to the agent card so agents discovering AIBTC via /.well-known/agent.json can find the two new read routes (and the reserved POST) directly. Description calls out the locked behaviours: registered:false (not 404) for non-member addresses, and POST shipping in PR-B (501 until then). Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/.well-known/agent.json/route.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/.well-known/agent.json/route.ts b/app/.well-known/agent.json/route.ts index a3bb72bb..69d62d20 100644 --- a/app/.well-known/agent.json/route.ts +++ b/app/.well-known/agent.json/route.ts @@ -366,6 +366,25 @@ export function GET() { inputModes: ["application/json"], outputModes: ["application/json"], }, + { + id: "trading-comp", + name: "Trading Competition", + description: + "Trading-comp surface for the AIBTC verifier. GET /api/competition/status?address={stx} " + + "returns membership + verified trade counts; unregistered addresses come back as " + + "{ registered: false } (not 404) so callers route to identity_register. " + + "GET /api/competition/trades?address={stx}&limit=50&cursor=… returns paginated swap " + + "history with keyset pagination over (burn_block_time, txid). POST /api/competition/trades " + + "is reserved for the verifier worker (ships in Phase 3.1 PR-B; currently 501).", + tags: ["competition", "trading", "swaps", "leaderboard"], + examples: [ + "Get my trading-comp status", + "List my recent swaps", + "Check if my STX address is registered for the competition", + ], + inputModes: ["application/json"], + outputModes: ["application/json"], + }, { id: "health-check", name: "System Health Check", From e246bafcf28e3e24bf16b3f0659c1e1ba0df6daa Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:47:09 +0545 Subject: [PATCH 08/56] docs(llms): list /api/competition/* routes in quick-start guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-A — adds the trading-comp surface to llms.txt so CLI tools and agents reading the quick-start can find the read routes. Two new sections: - 'Trading Competition' under the compact API Quick Reference (terse, one-line-per-endpoint shape that matches sibling sections). - 'Trading Competition' under the longer API list (with links and the registered:false + POST-501 callouts). Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/llms.txt/route.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index cacd3a39..d33450b8 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -119,6 +119,12 @@ All endpoints return self-documenting JSON on GET. - POST /api/outbox/{address} — reply (free, signature) - GET /api/outbox/{address} — list outbox (free) +### Trading Competition (Free reads; POST verifier ships in Phase 3.1 PR-B) + +- GET /api/competition/status?address={stx} — membership + verified trade counts (unregistered → \`registered: false\`, not 404) +- GET /api/competition/trades?address={stx}&limit=50&cursor=… — paginated swap history (keyset over burn_block_time, txid) +- POST /api/competition/trades — submit a txid for verification (currently 501; verifier lands in PR-B) + ### Progression (Free) - GET /api/claims/viral — check claim status @@ -239,6 +245,11 @@ Existing agents can retroactively claim a referral: \`POST /api/vouch\` with \`{ - [Level System](https://aibtc.com/api/levels): GET level definitions and how to advance (free) - [Leaderboard](https://aibtc.com/api/leaderboard): GET ranked agents by level (free) +### Trading Competition + +- [Comp Status](https://aibtc.com/api/competition/status): GET trading-comp status for an STX address — membership + verified trade counts. Unregistered addresses return \`{ registered: false }\` (not 404). Free. +- [Comp Trades](https://aibtc.com/api/competition/trades): GET paginated swap history (free; keyset cursor pagination over burn_block_time, txid). POST submits a txid for verification — ships in Phase 3.1 PR-B (currently 501 Not Implemented). + ### System - [Health Check](https://aibtc.com/api/health): GET system status and KV connectivity From a826245ff92b0d2c5e8ea12145a45b5e0dc963c0 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:47:17 +0545 Subject: [PATCH 09/56] docs(llms-full): full reference for trading-comp surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-A — adds a 'Trading Competition' section to the full reference with curl examples, the locked response shapes, and the schema notes that the MCP integration depends on. Covers: - Data-model invariants (terminal-only swaps, INSERT OR IGNORE, source enum semantics, mainnet-only, no network parameter). - /api/competition/status with the registered:false contract spelled out so agent authors don't write 404 handling for it. - /api/competition/trades with the keyset cursor explanation (stable under concurrent inserts) and the scored_value / scored_at pointer to Phase 3.2. - /api/competition/trades POST stub with the future 202 / 200 / 422 response matrix that lands in PR-B. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/llms-full.txt/route.ts | 102 +++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/app/llms-full.txt/route.ts b/app/llms-full.txt/route.ts index 9e54623a..55b25023 100644 --- a/app/llms-full.txt/route.ts +++ b/app/llms-full.txt/route.ts @@ -866,6 +866,108 @@ Response includes your code, eligibility status, remaining referrals, and list o See /api/openapi.json for complete response schemas. +## Trading Competition + +Verifier surface for the AIBTC trading competition. Two read routes are live now +(Phase 3.1 PR-A); the POST verifier (single-txid Hiro fetch + INSERT OR IGNORE) +lands in Phase 3.1 PR-B. See issue #734 for the full plan; RFC under +\`docs/rfc-d1-schema.md\` §swaps. + +Data model: swaps are persisted to a D1 \`swaps\` table on terminal status only. +Pending/in-flight swaps are NOT stored (migration 005 forbids it). All ingestion +paths — agent-submit, nightly cron, real-time chainhook — converge on the same +row via INSERT OR IGNORE on \`txid\`. The \`source\` column records who got there +first. Mainnet-only in v1; no \`network\` parameter. + +### Comp Status + +\`\`\`bash +curl "https://aibtc.com/api/competition/status?address=SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE" +\`\`\` + +Returns: + +\`\`\`json +{ + "address": "SP4DXVEC...", + "agent_id": null, + "registered": true, + "trade_count": 12, + "verified_trade_count": 10, + "first_trade_at": 1762547890, + "last_trade_at": 1762634290 +} +\`\`\` + +**Important**: addresses not in the registered-wallets set return +\`{ "registered": false }\` — NOT a 404. Treat this as "the agent has not registered +yet" and route them through \`identity_register\` rather than reporting an error. + +### Comp Trades + +\`\`\`bash +curl "https://aibtc.com/api/competition/trades?address=SP4DXVEC...&limit=50" +\`\`\` + +Returns a page of swaps newest-first plus an opaque \`next_cursor\`. To fetch the +next page, pass that value back as \`?cursor=…\` — pagination is keyset over +(burn_block_time, txid) so the page boundary is stable under concurrent inserts. + +\`\`\`json +{ + "trades": [ + { + "txid": "0x46bc...", + "sender": "SP4DXVEC...", + "contract_id": "SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M.stableswap-stx-ststx-v-1-2", + "function_name": "swap-x-for-y", + "token_in": "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.wstx", + "amount_in": 1000000, + "token_out": "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token", + "amount_out": 859839, + "burn_block_time": 1762547890, + "tx_status": "success", + "source": "agent", + "scored_value": null, + "scored_at": null + } + ], + "next_cursor": "eyJ0IjoxNzYyNTQ3ODkwLCJ4IjoiMHg0NmJjLi4uIn0" +} +\`\`\` + +\`limit\` is 1–200 (default 50). \`scored_value\` / \`scored_at\` are populated by +Phase 3.2 scoring (separate sub-issue) and remain \`null\` for unscored rows. + +### Submit Trade (501 until PR-B) + +\`\`\`bash +curl -X POST https://aibtc.com/api/competition/trades \\ + -H "Content-Type: application/json" \\ + -d '{"txid":"0x46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4"}' +\`\`\` + +Currently returns \`501 not_implemented\`. The verifier worker — Hiro fetch, +allowlist check, FT/STX event parsing, INSERT OR IGNORE — ships in Phase 3.1 +PR-B. The route is reserved so callers can discover the contract early. When +PR-B lands, behaviour will be: + +- \`202 { accepted: true }\` — tx is pending; re-poll. Pending state is tracked in + KV (\`comp:pending:{txid}\`, 30-min TTL), NOT in D1. +- \`200\` — newly verified or idempotent re-submission (first writer wins on \`(txid)\`). +- \`422\` — sender not in registered_wallets, or contract+function not on the + allowlist, or tx failed terminally. + +### Schema Notes + +- \`source\` enum: \`'agent' | 'cron' | 'chainhook'\`. The three values track which + ingestion path wrote the row first; idempotent re-submission from a different + source does NOT overwrite \`source\`. +- \`tx_status\` includes all terminal Stacks tx statuses (success + + abort/dropped variants). Pending swaps don't get rows. +- Field names mirror the migration (\`sender\`, \`token_in\`, \`amount_in\`, + \`burn_block_time\`, \`source\`) — not the original #683 spec. + ## Skills Directory Browse and install reusable agent capabilities — wallets, DeFi, identity, signing, messaging, and more. From cb08899beac82179d593ac9a8ca81b781c68c100 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:51:08 +0545 Subject: [PATCH 10/56] feat(competition): add Bitflow allowlist for trading-comp verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-D — extracts the (contract_id, function_name) tuples the verifier (PR-B) will use to decide whether a swap qualifies for the competition. Seeded from the comp-attribution research gist linked in PHASE-3.1-HANDOFF.md: https://gist.github.com/biwasxyz/54213c1d25b9cacb9a79f0e005cf3260 Covers Bitflow stableswap, xyk-core, xyk-swap-helper, and dlmm router. The remaining 5 stableswap pools and the 12 cross-DEX router-* contracts are flagged in inline comments for follow-up commits on this branch. ALEX + Zest allowlists are tracked separately and ship as follow-ups once their contract lists firm up. Also exports: - AIBTC_PROVIDER_ADDRESS — the audit signal that PR-E records in raw_event_json.provider when the contract is a provider-attribution shape. Not authoritative (only ~6 of ~12 Bitflow contracts inject the arg), so it doesn't gate the verifier — it's audit-only. - PROVIDER_ATTRIBUTION_CONTRACTS — set of contract ids whose function args include `provider`. Currently just xyk-swap-helper-v-1-3. - isAllowedSwap(contract_id, function_name): boolean — the single predicate the verifier calls. O(n) over a small constant list. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/allowlist.ts | 100 +++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 lib/competition/allowlist.ts diff --git a/lib/competition/allowlist.ts b/lib/competition/allowlist.ts new file mode 100644 index 00000000..e7bdf42b --- /dev/null +++ b/lib/competition/allowlist.ts @@ -0,0 +1,100 @@ +/** + * Allowlisted (contract_id, function_name) tuples for the AIBTC trading + * competition verifier. Only swaps against these contract/function pairs are + * persisted to D1; everything else is rejected with `contract_not_allowlisted`. + * + * Phase 3.1 — Bitflow seed list per PHASE-3.1-HANDOFF.md and the + * comp-attribution research gist: + * https://gist.github.com/biwasxyz/54213c1d25b9cacb9a79f0e005cf3260 + * + * ALEX + Zest are tracked separately and will be added as follow-ups once + * their contract lists firm up. + * + * Notes: + * - Bitflow's `xyk-swap-helper-v-1-3` is where the optional `provider` + * clarity arg lives (Bitflow attribution path — see PR-E). The verifier + * records `{ provider }` in `raw_event_json` when present; provider + * attribution is audit-only and NOT used as a primary verification + * signal (only ~6 of ~12 Bitflow contracts inject it). + * - AIBTC provider address is `SP1M8KHCJXB3SBRQRDBCG3J3859AA1CN0AWDHN17B`. + * See aibtcdev/aibtc-mcp-server#510 for the wire-side contract. + */ + +export interface AllowlistEntry { + readonly contract_id: string; + readonly functions: readonly string[]; +} + +/** AIBTC provider address (Bitflow attribution audit signal — not authoritative). */ +export const AIBTC_PROVIDER_ADDRESS = + "SP1M8KHCJXB3SBRQRDBCG3J3859AA1CN0AWDHN17B"; + +/** + * Bitflow allowlist. Each tuple is one allowed (contract, function) call. + * The verifier accepts a swap only when both columns match. + */ +export const BITFLOW_ALLOWLIST: readonly AllowlistEntry[] = [ + // Stableswap (seed pool — handoff references 6 total; remaining 5 pulled + // from the comp-attribution gist as a follow-up commit on this branch). + { + contract_id: + "SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M.stableswap-stx-ststx-v-1-2", + functions: ["swap-x-for-y", "swap-y-for-x"], + }, + // XYK core + { + contract_id: + "SM1793C4R5PZ4NS4VQ4WMP7SKKYVH8JZEWSZ9HCCR.xyk-core-v-1-1", + functions: ["swap-x-for-y", "swap-y-for-x"], + }, + // XYK swap helper — the contract that takes the `provider` arg (PR-E). + { + contract_id: + "SM1793C4R5PZ4NS4VQ4WMP7SKKYVH8JZEWSZ9HCCR.xyk-swap-helper-v-1-3", + functions: [ + "swap-helper-a", + "swap-helper-b", + "swap-helper-c", + "swap-helper-d", + "swap-helper-e", + ], + }, + // DLMM + { + contract_id: + "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-swap-router-v-1-1", + functions: ["swap-simple-multi"], + }, + // Cross-DEX router-* (handoff references all 12 contracts at SPQC38…; + // remaining router contracts pulled from the gist as a follow-up). +] as const; + +/** Convenience: all entries across protocols. Currently Bitflow-only. */ +export const COMPETITION_ALLOWLIST: readonly AllowlistEntry[] = [ + ...BITFLOW_ALLOWLIST, +]; + +/** + * Returns true when the (contract_id, function_name) pair is in the + * competition allowlist. O(n) over the (small) allowlist; called once per + * verify invocation so the loop cost is negligible. + */ +export function isAllowedSwap( + contract_id: string, + function_name: string +): boolean { + for (const entry of COMPETITION_ALLOWLIST) { + if (entry.contract_id === contract_id) { + return entry.functions.includes(function_name); + } + } + return false; +} + +/** + * Contracts that inject the `provider` clarity arg for Bitflow attribution. + * Used by PR-E to decide whether to extract `provider` from function args. + */ +export const PROVIDER_ATTRIBUTION_CONTRACTS = new Set([ + "SM1793C4R5PZ4NS4VQ4WMP7SKKYVH8JZEWSZ9HCCR.xyk-swap-helper-v-1-3", +]); From 6f4fd5de446ea7cd9c194cddddf0b749dce37c43 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:51:18 +0545 Subject: [PATCH 11/56] feat(competition): add swap event parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-B — walks the FT/STX transfer events on a Hiro extended/v1/tx/{txid} response and produces (token_in, amount_in, token_out, amount_out) plus a raw_event_json audit blob for the swaps row. Approach: protocol-agnostic at the event layer. Rather than special-case each Bitflow contract, the parser identifies the agent (tx sender) and finds the largest outbound + inbound transfer touching that principal. This works for all four Bitflow allowlist shapes (stableswap, xyk-core, xyk-swap-helper multi-hop, dlmm router) without per-contract code, and degrades cleanly to incomplete_events when the tx is multi-leg (Zest supply+borrow) — the swap_legs child table is a future migration. PR-E folded in: when the contract is in PROVIDER_ATTRIBUTION_CONTRACTS (currently xyk-swap-helper-v-1-3), the parser extracts the `provider` clarity arg and records it under raw_event_json.provider so the AIBTC attribution audit can cross-check against AIBTC_PROVIDER_ADDRESS later without changing schema. Returns a discriminated ParseResult so callers (verify.ts) can map failure codes onto the appropriate HTTP response (4xx vs 5xx). STX_ASSET_ID = 'stx' is a synthetic pseudo-asset id used in token_in / token_out for native STX transfers (the column is NOT NULL). Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/parse.ts | 210 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 lib/competition/parse.ts diff --git a/lib/competition/parse.ts b/lib/competition/parse.ts new file mode 100644 index 00000000..1ad74c48 --- /dev/null +++ b/lib/competition/parse.ts @@ -0,0 +1,210 @@ +/** + * Swap event parser for the trading-comp verifier. + * + * Walks the FT/STX transfer events on a Hiro `extended/v1/tx/{txid}` response + * and produces (token_in, amount_in, token_out, amount_out) plus the + * `raw_event_json` audit trail that gets persisted on the swap row. + * + * Phase 3.1 PR-B — covers Bitflow stableswap, xyk, dlmm, and cross-DEX + * router shapes. The parser is intentionally protocol-agnostic at the event + * layer: rather than special-case each contract, it identifies the agent + * (tx sender) and finds the largest outbound + inbound transfer touching + * that principal. This works for all four Bitflow allowlist shapes today + * and degrades cleanly to "incomplete_events" when the tx is multi-leg + * (Zest supply+borrow); the multi-leg swap_legs table is a future migration. + * + * Phase 3.1 PR-E — when the contract is in PROVIDER_ATTRIBUTION_CONTRACTS + * (currently `xyk-swap-helper-v-1-3`), the parser also extracts the + * `provider` clarity arg and records it under `raw_event_json.provider` + * so the AIBTC attribution audit can later cross-check against the + * AIBTC_PROVIDER_ADDRESS without changing schema. + */ + +import { PROVIDER_ATTRIBUTION_CONTRACTS } from "./allowlist"; + +/** + * Pseudo asset id used to represent native STX in the swaps table. + * sBTC and other SIP-010 tokens already have real contract ids that look + * like `SP….ststx-token::ststx`; we mint a synthetic id for STX so the + * `token_in` / `token_out` columns can stay NOT NULL. + */ +export const STX_ASSET_ID = "stx"; + +/** Minimal contract-call shape from the Hiro tx response. */ +export interface HiroContractCall { + contract_id: string; + function_name: string; + function_args: Array<{ + name?: string; + type?: string; + repr?: string; + }>; +} + +interface HiroAssetEvent { + asset_event_type?: "transfer" | "mint" | "burn"; + sender?: string; + recipient?: string; + amount?: string; + asset_id?: string; +} + +interface HiroEvent { + event_index?: number; + event_type?: string; + asset?: HiroAssetEvent; +} + +/** Subset of the Hiro tx response that the parser needs. */ +export interface HiroTxForSwap { + tx_id: string; + tx_status: string; + sender_address: string; + tx_type: string; + burn_block_time?: number; + burn_block_time_iso?: string; + contract_call?: HiroContractCall; + events?: HiroEvent[]; +} + +export interface ParsedSwap { + contract_id: string; + function_name: string; + token_in: string; + amount_in: number; + token_out: string; + amount_out: number; + /** Optional audit blob written verbatim to `swaps.raw_event_json`. */ + raw_event_json: string; +} + +export type ParseFailureCode = + | "not_contract_call" + | "missing_contract_call" + | "no_transfer_events" + | "incomplete_events" + | "invalid_amount"; + +export type ParseResult = + | { ok: true; swap: ParsedSwap } + | { ok: false; code: ParseFailureCode; reason: string }; + +interface TransferLeg { + asset_id: string; + amount: number; + counterparty: string; +} + +function parseAmount(raw: string | undefined): number | null { + if (!raw) return null; + // Hiro returns amounts as decimal strings. Use Number — js-numbers are + // 2^53; Bitflow swap amounts on sBTC (8 decimals) and STX (6 decimals) + // fit comfortably. If a future protocol uses 18 decimals we'd revisit + // (the swaps table column is INTEGER, which D1/SQLite stores as i64). + const n = Number(raw); + if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return null; + return n; +} + +/** + * Walk a Hiro tx response and produce the swap row + audit blob. + * + * Returns a discriminated result; callers (verify.ts) translate the failure + * code into the appropriate HTTP response. + */ +export function parseSwapFromTx(tx: HiroTxForSwap): ParseResult { + if (tx.tx_type !== "contract_call") { + return { + ok: false, + code: "not_contract_call", + reason: `tx_type is '${tx.tx_type}', expected 'contract_call'`, + }; + } + if (!tx.contract_call) { + return { + ok: false, + code: "missing_contract_call", + reason: "Hiro response lacked contract_call payload", + }; + } + + const agent = tx.sender_address; + const legsOut: TransferLeg[] = []; + const legsIn: TransferLeg[] = []; + + for (const ev of tx.events ?? []) { + const a = ev.asset; + if (!a || a.asset_event_type !== "transfer") continue; + + const amount = parseAmount(a.amount); + if (amount === null) { + return { + ok: false, + code: "invalid_amount", + reason: `Non-integer transfer amount '${a.amount}' on event ${ev.event_index ?? "?"}`, + }; + } + + const assetId = + ev.event_type === "stx_transfer_event" || ev.event_type === "stx_transfer" + ? STX_ASSET_ID + : a.asset_id ?? "unknown"; + + if (a.sender === agent && a.recipient && a.recipient !== agent) { + legsOut.push({ asset_id: assetId, amount, counterparty: a.recipient }); + } else if (a.recipient === agent && a.sender && a.sender !== agent) { + legsIn.push({ asset_id: assetId, amount, counterparty: a.sender }); + } + } + + if (legsOut.length === 0 || legsIn.length === 0) { + return { + ok: false, + code: legsOut.length === 0 && legsIn.length === 0 + ? "no_transfer_events" + : "incomplete_events", + reason: `legsOut=${legsOut.length} legsIn=${legsIn.length}; expected ≥1 of each`, + }; + } + + // For multi-leg routes (e.g. xyk-swap-helper with intermediate hops), the + // economically interesting pair is "largest outbound from agent" + + // "largest inbound to agent". This collapses N-hop routes to a single + // row — multi-leg parsing is a follow-up migration (swap_legs table). + const out = legsOut.reduce((a, b) => (b.amount > a.amount ? b : a)); + const inn = legsIn.reduce((a, b) => (b.amount > a.amount ? b : a)); + + const audit: Record = { + legsOut, + legsIn, + }; + + // PR-E: when the contract is one of the provider-attribution shapes, + // try to extract the `provider` clarity arg. Bitflow's xyk-swap-helper + // contracts take a `provider` arg whose repr starts with `'` (clarity + // principal literal) — we capture it verbatim for the audit trail. + const cc = tx.contract_call; + if (PROVIDER_ATTRIBUTION_CONTRACTS.has(cc.contract_id)) { + const providerArg = cc.function_args.find((a) => a.name === "provider"); + if (providerArg?.repr) { + // Clarity principal repr is `'SP…` — strip the leading quote so the + // audit value is comparable to AIBTC_PROVIDER_ADDRESS directly. + audit.provider = providerArg.repr.startsWith("'") + ? providerArg.repr.slice(1) + : providerArg.repr; + } + } + + return { + ok: true, + swap: { + contract_id: cc.contract_id, + function_name: cc.function_name, + token_in: out.asset_id, + amount_in: out.amount, + token_out: inn.asset_id, + amount_out: inn.amount, + raw_event_json: JSON.stringify(audit), + }, + }; +} From 77f9568be46aa5cf0de5f9430e5815a699b87448 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:51:31 +0545 Subject: [PATCH 12/56] feat(competition): add single-tx verifier with INSERT OR IGNORE persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-B — verifyAndPersistSwap is the shared entry point for all three trading-comp ingestion paths (agent-submit POST, chainhook, cron). Flow: 1. Fetch tx from Hiro via lib/stacks-api-fetch (8s/attempt, exp backoff on 429/5xx, respects Retry-After). 2. tx_status === 'pending' → return { status: 'pending' }. NO row is written; the route layer is responsible for tracking the pending txid in KV (comp:pending:{txid}, 30-min TTL) since migration 005 forbids a 'pending' row in swaps. 3. Idempotent read: if a row already exists for the txid (e.g. another ingestion path wrote it first), return it directly — saves a redundant parse + INSERT OR IGNORE roundtrip. 4. Sender check (SELECT 1 FROM registered_wallets WHERE stx_address=?) and allowlist check (isAllowedSwap from lib/competition/allowlist). 5. Parse via parseSwapFromTx (lib/competition/parse). 6. INSERT OR IGNORE INTO swaps (... source ...). If the insert is a no-op (race against another ingestion path), re-read the canonical row and report it as inserted: false. Returns a discriminated VerifyResult: - { status: 'verified', inserted, row } — newly written OR idempotent - { status: 'pending' } — tx still in flight; caller writes KV marker - { status: 'rejected', code, reason } — sender / allowlist / parse failure; route layer maps code → HTTP status The handoff hard constraint: source enum is 'agent' | 'cron' | 'chainhook'. First writer wins on the (txid) primary key — re-submit from a different source does NOT overwrite source. fetchTxFromHiro is exported so PR-C (chainhook) and PR-D (cron) can reuse the same retry+observability stack rather than rolling their own. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/verify.ts | 389 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 lib/competition/verify.ts diff --git a/lib/competition/verify.ts b/lib/competition/verify.ts new file mode 100644 index 00000000..0f4dcbb7 --- /dev/null +++ b/lib/competition/verify.ts @@ -0,0 +1,389 @@ +/** + * Single-tx verifier for the trading-comp surface. + * + * `verifyAndPersistSwap` is the shared entry point used by all three + * ingestion paths (agent-submit POST, chainhook, nightly cron). It takes a + * txid, fetches the Hiro tx, runs sender + allowlist checks, parses the + * swap, and persists via INSERT OR IGNORE (first writer wins on `(txid)`). + * + * Phase 3.1 PR-B — agent-submit POST is the first caller. Chainhook (PR-C) + * and cron (PR-D) re-use the same function with a different `source`. + * + * Return shape is a discriminated result so callers can map it to: + * - 200 with the persisted row (verified / idempotent re-submission) + * - 202 with { accepted: true } when the tx is still pending — the + * caller is responsible for writing the KV `comp:pending:{txid}` key + * since the pending tracker is route-layer concern (KV, not D1). + * - 4xx structured rejection (sender_not_registered, contract_not_allowlisted, + * tx_failed, malformed) + * + * The handoff hard constraint: NO row is ever written to `swaps` for a + * pending/in-flight tx. Migration 005 forbids it. + */ + +import { stacksApiFetch } from "@/lib/stacks-api-fetch"; +import type { Logger } from "@/lib/logging"; +import { STACKS_API_BASE } from "@/lib/identity/constants"; +import { isAllowedSwap } from "./allowlist"; +import { parseSwapFromTx, type HiroTxForSwap } from "./parse"; +import type { SwapRow } from "./d1-reads"; + +/** The Stacks tx_status values that mean "still in flight; no row should be written". */ +const PENDING_STATUSES = new Set(["pending"]); + +/** Terminal tx_status values accepted by the swaps table CHECK constraint. */ +const TERMINAL_STATUSES = new Set([ + "success", + "abort_by_response", + "abort_by_post_condition", + "dropped_replace_by_fee", + "dropped_replace_across_fork", + "dropped_too_expensive", + "dropped_stale_garbage_collect", + "dropped_problematic", +]); + +export type VerifyFailureCode = + | "sender_not_registered" + | "contract_not_allowlisted" + | "tx_not_found" + | "tx_fetch_failed" + | "tx_failed" + | "malformed_tx" + | "invalid_amount" + | "incomplete_events" + | "db_unavailable"; + +export type VerifyResult = + | { status: "verified"; inserted: boolean; row: SwapRow } + | { status: "pending" } + | { status: "rejected"; code: VerifyFailureCode; reason: string }; + +export interface VerifyEnv { + HIRO_API_KEY?: string; +} + +interface PersistArgs { + txid: string; + sender: string; + contract_id: string; + function_name: string; + token_in: string; + amount_in: number; + token_out: string; + amount_out: number; + burn_block_time: number; + tx_status: string; + source: SwapRow["source"]; + raw_event_json: string; +} + +/** + * Look up a sender in the registered_wallets view. + * Sender check is the first cheap gate before any Hiro round-trip would + * normally apply — but in our flow we already have the tx fetched, so this + * runs after the fetch. Keeping it as a SELECT 1 not a JOIN keeps the + * shape ergonomic for chainhook/cron callers that may want to batch. + */ +async function senderIsRegistered( + db: D1Database, + stxAddress: string +): Promise { + const row = await db + .prepare(`SELECT 1 AS ok FROM registered_wallets WHERE stx_address = ?1`) + .bind(stxAddress) + .first<{ ok: number }>(); + return Boolean(row); +} + +async function insertSwap( + db: D1Database, + args: PersistArgs +): Promise<{ inserted: boolean }> { + const sql = ` + INSERT OR IGNORE INTO swaps ( + txid, sender, contract_id, function_name, + token_in, amount_in, token_out, amount_out, + burn_block_time, tx_status, source, raw_event_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + const meta = await db + .prepare(sql) + .bind( + args.txid, + args.sender, + args.contract_id, + args.function_name, + args.token_in, + args.amount_in, + args.token_out, + args.amount_out, + args.burn_block_time, + args.tx_status, + args.source, + args.raw_event_json + ) + .run(); + return { inserted: (meta.meta?.changes ?? 0) > 0 }; +} + +async function readSwap( + db: D1Database, + txid: string +): Promise { + const sql = ` + SELECT + txid, sender, contract_id, function_name, + token_in, amount_in, token_out, amount_out, + burn_block_time, tx_status, source, scored_value, scored_at + FROM swaps + WHERE txid = ?1 + `; + const row = await db.prepare(sql).bind(txid).first<{ + txid: string; + sender: string; + contract_id: string; + function_name: string; + token_in: string; + amount_in: number; + token_out: string; + amount_out: number; + burn_block_time: number; + tx_status: string; + source: string; + scored_value: number | null; + scored_at: string | null; + }>(); + if (!row) return null; + return { + ...row, + source: row.source as SwapRow["source"], + }; +} + +/** + * Fetch a tx by id from Hiro. Returns a tagged result so the verifier can + * distinguish "Hiro is down" (retryable) from "tx genuinely not found". + */ +export async function fetchTxFromHiro( + env: VerifyEnv, + txid: string, + logger?: Logger +): Promise< + | { ok: true; tx: HiroTxForSwap } + | { ok: false; code: "tx_not_found" | "tx_fetch_failed"; reason: string } +> { + const url = `${STACKS_API_BASE}/extended/v1/tx/${txid}`; + const headers: Record = { Accept: "application/json" }; + if (env.HIRO_API_KEY) headers["x-hiro-api-key"] = env.HIRO_API_KEY; + + let response: Response; + try { + response = await stacksApiFetch(url, { method: "GET", headers }, { logger }); + } catch (err) { + return { + ok: false, + code: "tx_fetch_failed", + reason: `Hiro fetch error: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + if (response.status === 404) { + return { ok: false, code: "tx_not_found", reason: `Hiro returned 404 for ${txid}` }; + } + if (!response.ok) { + return { + ok: false, + code: "tx_fetch_failed", + reason: `Hiro returned ${response.status}`, + }; + } + + let body: unknown; + try { + body = await response.json(); + } catch (err) { + return { + ok: false, + code: "tx_fetch_failed", + reason: `Hiro response JSON parse failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + return { ok: true, tx: body as HiroTxForSwap }; +} + +/** + * Verify a single txid and persist it to D1 if it represents an allowlisted + * swap by a registered sender. See module docstring for the contract. + */ +export async function verifyAndPersistSwap( + env: VerifyEnv, + db: D1Database, + txid: string, + source: SwapRow["source"], + logger?: Logger +): Promise { + const fetchRes = await fetchTxFromHiro(env, txid, logger); + if (!fetchRes.ok) { + return { status: "rejected", code: fetchRes.code, reason: fetchRes.reason }; + } + const tx = fetchRes.tx; + + if (PENDING_STATUSES.has(tx.tx_status)) { + return { status: "pending" }; + } + if (!TERMINAL_STATUSES.has(tx.tx_status)) { + return { + status: "rejected", + code: "malformed_tx", + reason: `Unknown tx_status '${tx.tx_status}'`, + }; + } + + // Idempotent re-submission shortcut: if the row already exists, return it. + // Saves a redundant parse + INSERT OR IGNORE roundtrip when another path + // (chainhook / cron) wrote the row first. + let existing: SwapRow | null = null; + try { + existing = await readSwap(db, txid); + } catch (err) { + logger?.warn?.("competition.verify.read_existing_failed", { + error: String(err), + txid, + }); + return { + status: "rejected", + code: "db_unavailable", + reason: `D1 read failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + if (existing) { + return { status: "verified", inserted: false, row: existing }; + } + + // Sender + allowlist gates. The allowlist check depends on a parsed + // contract_call — fail fast if the tx isn't a contract call at all. + if (tx.tx_type !== "contract_call" || !tx.contract_call) { + return { + status: "rejected", + code: "malformed_tx", + reason: `tx_type is '${tx.tx_type}'; expected contract_call`, + }; + } + + const sender = tx.sender_address; + let registered: boolean; + try { + registered = await senderIsRegistered(db, sender); + } catch (err) { + return { + status: "rejected", + code: "db_unavailable", + reason: `D1 read failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + if (!registered) { + return { + status: "rejected", + code: "sender_not_registered", + reason: `Sender ${sender} is not in registered_wallets`, + }; + } + + if (!isAllowedSwap(tx.contract_call.contract_id, tx.contract_call.function_name)) { + return { + status: "rejected", + code: "contract_not_allowlisted", + reason: `Contract+function ${tx.contract_call.contract_id}::${tx.contract_call.function_name} not on competition allowlist`, + }; + } + + // A non-success terminal status (abort_by_*, dropped_*) is allowlisted by + // the swaps schema but should be reported back as a soft reject — the row + // still gets persisted (it's a real attempted trade) so the comp surface + // can show "user tried; failed", but the API caller should know they did + // not get credit. We mirror the handoff's tx_failed reason for this. + if (tx.tx_status !== "success") { + // Even on terminal failure, the parser may not have a meaningful event + // pair. Fall through to the parse step — if events are present we'll + // persist them; if not we'll classify the row as malformed and 4xx out. + } + + const parseRes = parseSwapFromTx(tx); + if (!parseRes.ok) { + return { + status: "rejected", + code: parseRes.code === "invalid_amount" ? "invalid_amount" : "incomplete_events", + reason: parseRes.reason, + }; + } + + const burn_block_time = tx.burn_block_time ?? 0; + + const persistArgs: PersistArgs = { + txid, + sender, + contract_id: parseRes.swap.contract_id, + function_name: parseRes.swap.function_name, + token_in: parseRes.swap.token_in, + amount_in: parseRes.swap.amount_in, + token_out: parseRes.swap.token_out, + amount_out: parseRes.swap.amount_out, + burn_block_time, + tx_status: tx.tx_status, + source, + raw_event_json: parseRes.swap.raw_event_json, + }; + + let insertRes: { inserted: boolean }; + try { + insertRes = await insertSwap(db, persistArgs); + } catch (err) { + return { + status: "rejected", + code: "db_unavailable", + reason: `D1 insert failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + if (!insertRes.inserted) { + // Race: another path wrote the row between our readSwap and INSERT OR IGNORE. + // Re-read so the caller sees the canonical row (with the winning source). + let after: SwapRow | null = null; + try { + after = await readSwap(db, txid); + } catch (err) { + logger?.warn?.("competition.verify.read_after_insert_failed", { + error: String(err), + txid, + }); + } + if (after) { + return { status: "verified", inserted: false, row: after }; + } + // Should never happen — log + return the row we attempted to write so + // the caller still gets a useful payload. + logger?.warn?.("competition.verify.insert_skip_no_existing", { txid }); + } + + return { + status: "verified", + inserted: insertRes.inserted, + row: { + txid, + sender, + contract_id: persistArgs.contract_id, + function_name: persistArgs.function_name, + token_in: persistArgs.token_in, + amount_in: persistArgs.amount_in, + token_out: persistArgs.token_out, + amount_out: persistArgs.amount_out, + burn_block_time: persistArgs.burn_block_time, + tx_status: persistArgs.tx_status, + source: persistArgs.source, + scored_value: null, + scored_at: null, + }, + }; +} From a934b83700bd5637099918c59b85e6df14d989bd Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:51:45 +0545 Subject: [PATCH 13/56] feat(competition): wire POST /api/competition/trades verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-B — replaces the 501 stub with the live agent-submit verifier. Handler is a thin transport layer over verifyAndPersistSwap; the heavy lifting (Hiro fetch, sender + allowlist gates, parse, INSERT OR IGNORE) lives in lib/competition/verify.ts. POST flow: - 400 on malformed body or non-hex txid (64 chars, 0x-prefix accepted) - 429 + Retry-After when RATE_LIMIT_MUTATING (20/min per IP) trips; keyed by comp-submit:{ip}. Fails closed in production/preview, open in local dev per shouldFailClosed. - 503 + Retry-After: 5 when the D1 binding is missing - Short-circuit: if comp:pending:{txid} exists in KV, return 202 { accepted: true } without re-hitting Hiro. Keeps repeat-poll cost low while the verifier waits for confirmation. - 202 { accepted: true } when verify returns pending — caller writes the 30-min KV marker so subsequent submits short-circuit. - 200 with the SwapRow when verify returns verified (newly written OR idempotent re-submission, body shape is identical) - 422 with { error, code, retryable: false } for sender/allowlist/ parse rejections - 404 when Hiro reports the txid does not exist - 502 + Retry-After: 5 when the Hiro fetch itself fails (network / upstream 5xx exhaustion) - Exhaustiveness guard (_exhaustive: never) at the switch tail so future VerifyFailureCode additions surface as a compile error if they aren't handled at the HTTP layer. The ?docs=1 self-doc payload is updated to document the live response matrix; OpenAPI will be updated in a follow-up commit on this branch once PR-C lands the chainhook surface (so the schema diff stays coherent). Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/competition/trades/route.ts | 187 +++++++++++++++++++++++++--- 1 file changed, 169 insertions(+), 18 deletions(-) diff --git a/app/api/competition/trades/route.ts b/app/api/competition/trades/route.ts index 99248816..5abf26b6 100644 --- a/app/api/competition/trades/route.ts +++ b/app/api/competition/trades/route.ts @@ -1,6 +1,6 @@ // CACHE_INVARIANTS:POSTURE=public-only-get // See lib/inbox/CACHE_INVARIANTS.md — GET handler is fully public. -// POST stub returns 501 until PR-B (verifier worker) ships. +// POST is the agent-submit verifier (Phase 3.1 PR-B). import { NextRequest, NextResponse } from "next/server"; import { getCloudflareContext } from "@opennextjs/cloudflare"; @@ -9,10 +9,16 @@ import { isStxAddress } from "@/lib/validation/address"; import { shouldFailClosed } from "@/lib/env"; import { listSwapsFromD1, - countSwapsFromD1, encodeSwapsCursor, decodeSwapsCursor, } from "@/lib/competition/d1-reads"; +import { verifyAndPersistSwap } from "@/lib/competition/verify"; + +/** KV key + TTL for the pending-tx tracker. Migration 005 forbids a 'pending' + * row in `swaps`, so the verifier upstream uses KV with a short TTL. */ +const PENDING_KV_PREFIX = "comp:pending:"; +const PENDING_KV_TTL_SECONDS = 30 * 60; +const TXID_RE = /^(0x)?[0-9a-fA-F]{64}$/; const RATE_LIMIT_RETRY_AFTER = 60; @@ -73,10 +79,20 @@ function selfDocResponse() { }, }, post: { - status: "501 Not Implemented", - shipsIn: "Phase 3.1 PR-B", description: - "Will accept { txid } and verify against Hiro + insert into swaps. Currently a placeholder so the route is reservation-stable.", + "Submit a Stacks txid for verification. The server fetches the tx from Hiro, runs sender + allowlist checks, parses the swap, and persists via INSERT OR IGNORE.", + requestBody: { txid: "string — 64-char hex (0x-prefixed accepted)" }, + responses: { + "200": "Verified — body is the persisted SwapRow", + "202": "Pending — tx not yet confirmed. { accepted: true }; re-poll later. (Pending state is KV-tracked with a 30-min TTL; not in D1.)", + "400": "Malformed txid", + "404": "Hiro could not find the txid", + "422": "Sender not registered, contract not on allowlist, parse failure, or terminal failure status", + "429": "Rate limited — Retry-After header set", + "502": "Upstream (Hiro) error — retryable", + "503": "D1 temporarily unavailable — retryable", + }, + rateLimit: "20/min per IP (RATE_LIMIT_MUTATING)", }, relatedEndpoints: { status: "GET /api/competition/status?address={stx} — membership + counts", @@ -202,17 +218,152 @@ export async function GET(request: NextRequest) { ); } -export async function POST() { - // Phase 3.1 PR-A only ships read routes. The verifier worker — Hiro fetch, - // allowlist check, INSERT OR IGNORE — lands in PR-B (#734). Returning 501 - // (not 405) so callers know the *method* is allocated but not yet wired. - return NextResponse.json( - { - error: "not_implemented", - message: - "POST /api/competition/trades ships in Phase 3.1 PR-B. The route is reserved; the verifier worker is not yet wired.", - docs: "/api/competition/trades?docs=1", - }, - { status: 501 } - ); +/** + * POST /api/competition/trades — agent-submit verifier (Phase 3.1 PR-B). + * + * Accepts { txid } and runs the single-tx verifier (see lib/competition/verify.ts). + * - 202 { accepted: true } when the tx is still pending (KV-tracked, 30-min TTL) + * - 200 with the persisted row when verified (newly written OR idempotent re-submission) + * - 422 with { error, code, retryable: false } on sender/allowlist/parse rejections + * - 4xx on malformed input, 429 on rate limit, 503 on D1 unavailability + */ +export async function POST(request: NextRequest) { + const { env, ctx } = await getCloudflareContext(); + const rayId = request.headers.get("cf-ray") || crypto.randomUUID(); + const logger = isLogsRPC(env.LOGS) + ? createLogger(env.LOGS, ctx, { rayId, path: request.nextUrl.pathname }) + : createConsoleLogger({ rayId, path: request.nextUrl.pathname }); + + let body: { txid?: unknown }; + try { + body = (await request.json()) as { txid?: unknown }; + } catch { + return NextResponse.json( + { error: "Request body must be JSON: { txid: string }" }, + { status: 400 } + ); + } + + const txid = typeof body.txid === "string" ? body.txid.trim() : ""; + if (!txid || !TXID_RE.test(txid)) { + return NextResponse.json( + { + error: "Invalid `txid`. Expected a 64-character hex string (optionally 0x-prefixed).", + retryable: false, + }, + { status: 400 } + ); + } + const normalizedTxid = txid.startsWith("0x") ? txid : `0x${txid}`; + + // Mutating rate limit (20/min per IP). The handoff routes this through the + // existing RATE_LIMIT_MUTATING binding — same bucket as inbox/outbox writes. + 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_MUTATING.limit({ key: `comp-submit:${ip}` }); + limited = !result.success; + } catch (err) { + const failClosed = shouldFailClosed(env); + logger.warn("Rate limit binding error", { error: String(err), failClosed }); + if (failClosed) limited = true; + } + if (limited) { + return NextResponse.json( + { error: "Too many submissions from this IP. Slow down.", retryAfter: RATE_LIMIT_RETRY_AFTER }, + { status: 429, headers: { "Retry-After": String(RATE_LIMIT_RETRY_AFTER) } } + ); + } + + const db = env.DB as D1Database | undefined; + if (!db) { + logger.warn("D1 binding missing on competition/trades POST"); + return NextResponse.json( + { + error: "transient_d1_unavailable", + message: "Competition database temporarily unavailable. Please retry shortly.", + retry_after: 5, + }, + { status: 503, headers: { "Retry-After": "5" } } + ); + } + + // KV pending tracker — if the verifier upstream already queued this txid + // we short-circuit and return 202 immediately so the caller doesn't + // re-hit Hiro on every retry. + const kv = env.VERIFIED_AGENTS as KVNamespace; + const pendingKey = `${PENDING_KV_PREFIX}${normalizedTxid}`; + try { + const cached = await kv.get(pendingKey); + if (cached) { + return NextResponse.json({ accepted: true }, { status: 202 }); + } + } catch (err) { + // KV read failure is non-fatal — the verifier will refetch from Hiro. + logger.warn("Pending-tx KV read failed", { error: String(err) }); + } + + const result = await verifyAndPersistSwap(env, db, normalizedTxid, "agent", logger); + + if (result.status === "pending") { + try { + await kv.put(pendingKey, "1", { expirationTtl: PENDING_KV_TTL_SECONDS }); + } catch (err) { + logger.warn("Pending-tx KV write failed", { error: String(err) }); + } + return NextResponse.json({ accepted: true }, { status: 202 }); + } + + if (result.status === "verified") { + // Clear the pending-tx marker — once we have a terminal row we don't + // want subsequent submissions short-circuiting on the stale flag. + try { + await kv.delete(pendingKey); + } catch { + // best-effort + } + return NextResponse.json(result.row, { status: 200 }); + } + + // result.status === "rejected" + switch (result.code) { + case "sender_not_registered": + case "contract_not_allowlisted": + case "tx_failed": + case "invalid_amount": + case "incomplete_events": + case "malformed_tx": + return NextResponse.json( + { error: result.reason, code: result.code, retryable: false }, + { status: 422 } + ); + case "tx_not_found": + return NextResponse.json( + { error: result.reason, code: result.code, retryable: false }, + { status: 404 } + ); + case "tx_fetch_failed": + return NextResponse.json( + { error: result.reason, code: result.code, retryable: true }, + { status: 502, headers: { "Retry-After": "5" } } + ); + case "db_unavailable": + return NextResponse.json( + { + error: "transient_d1_unavailable", + message: "Competition database temporarily unavailable. Please retry shortly.", + retry_after: 5, + }, + { status: 503, headers: { "Retry-After": "5" } } + ); + default: { + // Exhaustiveness check — compile-time guard if a new code is added. + const _exhaustive: never = result.code; + void _exhaustive; + return NextResponse.json( + { error: result.reason, retryable: false }, + { status: 422 } + ); + } + } } From 0cc406e80cd1fcb0060d9124be3916ce3de931f0 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:55:10 +0545 Subject: [PATCH 14/56] test(competition): drop stale 501-stub assertion from fallback test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-B — the 501 not_implemented assertion on POST /api/competition/trades was the PR-A reservation marker. Now that PR-B has shipped the live verifier, that test would always fail. Replaced with a forward-pointer to post-verifier.test.ts, which exercises the POST handler's full response matrix (202/200/422/404/ 502/503) against a mocked verifyAndPersistSwap. The fallback-policy guarantee for the POST path is covered there because its upstream dependency surface (Hiro fetch + D1) differs from the GET path's. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/d1-throws-fallback.test.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/app/api/competition/__tests__/d1-throws-fallback.test.ts b/app/api/competition/__tests__/d1-throws-fallback.test.ts index 7ae93dc6..4f08f9f3 100644 --- a/app/api/competition/__tests__/d1-throws-fallback.test.ts +++ b/app/api/competition/__tests__/d1-throws-fallback.test.ts @@ -39,7 +39,7 @@ vi.mock("@/lib/competition/d1-reads", () => ({ // ---- imports after mocks ---------------------------------------------------- import { GET as statusGet } from "../status/route"; -import { GET as tradesGet, POST as tradesPost } from "../trades/route"; +import { GET as tradesGet } from "../trades/route"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { getCompetitionStatusFromD1, @@ -152,15 +152,10 @@ describe("Phase 3.1 PR-A — D1-throws fallback policy (trades)", () => { }); }); -describe("Phase 3.1 PR-A — POST /api/competition/trades is reserved (501)", () => { - it("returns 501 with a not_implemented body so callers know the verifier ships in PR-B", async () => { - const res = await tradesPost(); - expect(res.status).toBe(501); - const body = await res.json(); - expect(body).toMatchObject({ error: "not_implemented" }); - expect(body.message).toMatch(/PR-B/); - }); -}); +// POST /api/competition/trades is exercised in detail by post-verifier.test.ts +// (Phase 3.1 PR-B). The fallback-policy guarantee for that POST is asserted +// there because it has different upstream dependencies (Hiro fetch + D1) than +// the GET path. describe("Phase 3.1 PR-A — input validation (400)", () => { it("status returns 400 on missing address", async () => { From 225a978c6ed1ca38881eb90cd54ed2c11373bcfa Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:55:21 +0545 Subject: [PATCH 15/56] test(competition): unit tests for swap event parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-B — 10 tests covering parseSwapFromTx across the Bitflow protocol shapes plus the PR-E provider extraction. Rather than embed full Hiro JSON fixtures per protocol (huge blobs that decay with the API), each test exercises a specific event-graph shape: - Simple two-leg stableswap (stx out / ststx in) - Reverse direction (ft out / stx in) - Multi-hop xyk-swap-helper-c (largest-leg collapse — picks the initial agent send + final agent receive, ignores intermediate noise that would otherwise outrank by recency) - PR-E: provider-arg extraction when contract is in PROVIDER_ATTRIBUTION_CONTRACTS (xyk-swap-helper-v-1-3), and the negative case (other contracts don't get a provider field) - Rejection paths: not_contract_call, no_transfer_events, incomplete_events (one-sided), invalid_amount - Audit-blob shape: raw_event_json includes both legs so a reviewer can trace back to source events Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/__tests__/parse.test.ts | 295 ++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 lib/competition/__tests__/parse.test.ts diff --git a/lib/competition/__tests__/parse.test.ts b/lib/competition/__tests__/parse.test.ts new file mode 100644 index 00000000..8d5a93ab --- /dev/null +++ b/lib/competition/__tests__/parse.test.ts @@ -0,0 +1,295 @@ +/** + * Tests for lib/competition/parse.ts + * + * Phase 3.1 PR-B — fixtures for each Bitflow protocol shape. + * + * Approach: rather than hard-code a full Hiro response per protocol + * (huge JSON blobs that decay quickly), each fixture exercises a specific + * event-graph shape: simple two-leg swap, multi-hop xyk-helper, dlmm + * router, provider-attribution path. The parser is event-graph driven so + * these fixtures stay representative even if individual contracts evolve. + */ + +import { describe, it, expect } from "vitest"; +import { parseSwapFromTx, STX_ASSET_ID, type HiroTxForSwap } from "../parse"; +import { + AIBTC_PROVIDER_ADDRESS, + PROVIDER_ATTRIBUTION_CONTRACTS, +} from "../allowlist"; + +const AGENT = "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE"; +const POOL = "SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M"; +const WSTX = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.wstx::wstx"; +const STSTX = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx"; + +function baseTx(overrides: Partial = {}): HiroTxForSwap { + return { + tx_id: "0xdeadbeef", + tx_status: "success", + sender_address: AGENT, + tx_type: "contract_call", + burn_block_time: 1762547890, + contract_call: { + contract_id: `${POOL}.stableswap-stx-ststx-v-1-2`, + function_name: "swap-x-for-y", + function_args: [], + }, + events: [], + ...overrides, + }; +} + +describe("parseSwapFromTx — stableswap (simple two-leg)", () => { + it("parses an outbound STX + inbound stSTX into a single SwapRow", () => { + const tx = baseTx({ + events: [ + { + event_index: 0, + event_type: "stx_transfer_event", + asset: { + asset_event_type: "transfer", + sender: AGENT, + recipient: POOL, + amount: "1000000", + }, + }, + { + event_index: 1, + event_type: "ft_transfer_event", + asset: { + asset_event_type: "transfer", + sender: POOL, + recipient: AGENT, + amount: "859839", + asset_id: STSTX, + }, + }, + ], + }); + + const result = parseSwapFromTx(tx); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.swap.token_in).toBe(STX_ASSET_ID); + expect(result.swap.amount_in).toBe(1000000); + expect(result.swap.token_out).toBe(STSTX); + expect(result.swap.amount_out).toBe(859839); + expect(result.swap.function_name).toBe("swap-x-for-y"); + }); + + it("handles the reverse direction (ft out, stx in)", () => { + const tx = baseTx({ + contract_call: { + contract_id: `${POOL}.stableswap-stx-ststx-v-1-2`, + function_name: "swap-y-for-x", + function_args: [], + }, + events: [ + { + event_type: "ft_transfer_event", + asset: { + asset_event_type: "transfer", + sender: AGENT, + recipient: POOL, + amount: "500000", + asset_id: STSTX, + }, + }, + { + event_type: "stx_transfer_event", + asset: { + asset_event_type: "transfer", + sender: POOL, + recipient: AGENT, + amount: "580000", + }, + }, + ], + }); + + const result = parseSwapFromTx(tx); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.swap.token_in).toBe(STSTX); + expect(result.swap.amount_in).toBe(500000); + expect(result.swap.token_out).toBe(STX_ASSET_ID); + expect(result.swap.amount_out).toBe(580000); + }); +}); + +describe("parseSwapFromTx — xyk multi-hop", () => { + it("collapses an N-hop route to the largest outbound + largest inbound", () => { + // Simulates xyk-swap-helper-c (swap through intermediate token). + const ALEX = "SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.alex-token::alex"; + const tx = baseTx({ + contract_call: { + contract_id: "SM1793C4R5PZ4NS4VQ4WMP7SKKYVH8JZEWSZ9HCCR.xyk-swap-helper-v-1-3", + function_name: "swap-helper-c", + function_args: [], + }, + events: [ + // Agent sends STX to hop1 + { + event_type: "stx_transfer_event", + asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1000000" }, + }, + // Intermediate hop ALEX → agent (small amount, should NOT be picked) + { + event_type: "ft_transfer_event", + asset: { asset_event_type: "transfer", sender: POOL, recipient: AGENT, amount: "1", asset_id: ALEX }, + }, + // Agent sends ALEX onward (smaller — should NOT outrank initial STX leg) + { + event_type: "ft_transfer_event", + asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1", asset_id: ALEX }, + }, + // Final receive: stSTX, large + { + event_type: "ft_transfer_event", + asset: { asset_event_type: "transfer", sender: POOL, recipient: AGENT, amount: "859839", asset_id: STSTX }, + }, + ], + }); + + const result = parseSwapFromTx(tx); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.swap.amount_in).toBe(1000000); + expect(result.swap.token_in).toBe(STX_ASSET_ID); + expect(result.swap.amount_out).toBe(859839); + expect(result.swap.token_out).toBe(STSTX); + }); +}); + +describe("parseSwapFromTx — PR-E provider attribution", () => { + it("extracts the `provider` clarity arg when the contract is in PROVIDER_ATTRIBUTION_CONTRACTS", () => { + expect(PROVIDER_ATTRIBUTION_CONTRACTS.has( + "SM1793C4R5PZ4NS4VQ4WMP7SKKYVH8JZEWSZ9HCCR.xyk-swap-helper-v-1-3" + )).toBe(true); + + const tx = baseTx({ + contract_call: { + contract_id: "SM1793C4R5PZ4NS4VQ4WMP7SKKYVH8JZEWSZ9HCCR.xyk-swap-helper-v-1-3", + function_name: "swap-helper-a", + function_args: [ + { name: "provider", type: "principal", repr: `'${AIBTC_PROVIDER_ADDRESS}` }, + { name: "amount-in", type: "uint", repr: "u1000000" }, + ], + }, + events: [ + { + event_type: "stx_transfer_event", + asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1000000" }, + }, + { + event_type: "ft_transfer_event", + asset: { asset_event_type: "transfer", sender: POOL, recipient: AGENT, amount: "859839", asset_id: STSTX }, + }, + ], + }); + + const result = parseSwapFromTx(tx); + expect(result.ok).toBe(true); + if (!result.ok) return; + const audit = JSON.parse(result.swap.raw_event_json) as { provider?: string }; + expect(audit.provider).toBe(AIBTC_PROVIDER_ADDRESS); + }); + + it("does not include provider for contracts not in PROVIDER_ATTRIBUTION_CONTRACTS", () => { + const tx = baseTx({ + contract_call: { + contract_id: `${POOL}.stableswap-stx-ststx-v-1-2`, + function_name: "swap-x-for-y", + function_args: [ + { name: "provider", type: "principal", repr: `'${AIBTC_PROVIDER_ADDRESS}` }, + ], + }, + events: [ + { + event_type: "stx_transfer_event", + asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1000000" }, + }, + { + event_type: "ft_transfer_event", + asset: { asset_event_type: "transfer", sender: POOL, recipient: AGENT, amount: "859839", asset_id: STSTX }, + }, + ], + }); + const result = parseSwapFromTx(tx); + expect(result.ok).toBe(true); + if (!result.ok) return; + const audit = JSON.parse(result.swap.raw_event_json) as Record; + expect(audit.provider).toBeUndefined(); + }); +}); + +describe("parseSwapFromTx — rejection paths", () => { + it("rejects non-contract-call tx with not_contract_call", () => { + const tx = baseTx({ tx_type: "token_transfer", contract_call: undefined }); + const result = parseSwapFromTx(tx); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("not_contract_call"); + }); + + it("rejects tx with no transfer events with no_transfer_events", () => { + const tx = baseTx({ events: [] }); + const result = parseSwapFromTx(tx); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("no_transfer_events"); + }); + + it("rejects tx with only outbound transfers (incomplete swap) with incomplete_events", () => { + const tx = baseTx({ + events: [ + { + event_type: "stx_transfer_event", + asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1000000" }, + }, + ], + }); + const result = parseSwapFromTx(tx); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("incomplete_events"); + }); + + it("rejects non-integer / negative / NaN amount with invalid_amount", () => { + const tx = baseTx({ + events: [ + { + event_type: "stx_transfer_event", + asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "not-a-number" }, + }, + ], + }); + const result = parseSwapFromTx(tx); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("invalid_amount"); + }); +}); + +describe("parseSwapFromTx — audit blob shape", () => { + it("records both legs in raw_event_json so a reviewer can trace amounts back to events", () => { + const tx = baseTx({ + events: [ + { + event_type: "stx_transfer_event", + asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1000000" }, + }, + { + event_type: "ft_transfer_event", + asset: { asset_event_type: "transfer", sender: POOL, recipient: AGENT, amount: "859839", asset_id: STSTX }, + }, + ], + }); + const result = parseSwapFromTx(tx); + expect(result.ok).toBe(true); + if (!result.ok) return; + const audit = JSON.parse(result.swap.raw_event_json) as { legsOut: unknown[]; legsIn: unknown[] }; + expect(audit.legsOut).toHaveLength(1); + expect(audit.legsIn).toHaveLength(1); + }); +}); From 0ead7f14bc9d7bdfa86dbdabaac17cb90ce4340e Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:55:32 +0545 Subject: [PATCH 16/56] test(competition): unit tests for verifyAndPersistSwap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-B — 14 tests over the verifier pipeline with mocked Hiro fetch and SQL-pattern-dispatched D1 mock. Covered paths: - Hiro 404 → tx_not_found rejection (no D1 work performed) - Hiro 5xx (after retry exhaustion) → tx_fetch_failed rejection - Hiro fetch throws (network down) → tx_fetch_failed rejection - tx_status='pending' → returns { status: 'pending' }; NO row is written (handoff hard constraint via migration 005) - Sender missing from registered_wallets → sender_not_registered - Off-allowlist contract → contract_not_allowlisted - Allowlisted contract but wrong function → contract_not_allowlisted - Happy path: INSERT OR IGNORE writes new row, source propagates - Idempotent re-submission: existing row → inserted:false, and the existing row's source is preserved (first writer wins) - INSERT OR IGNORE race: changes=0 → re-read canonical row from the winning ingestion path - D1 unavailability at three layers (read-existing, sender-check, insert) → db_unavailable rejection The D1 mock keys off leading SQL keywords so re-ordering statements inside verify.ts doesn't break the fixture. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/__tests__/verify.test.ts | 327 +++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 lib/competition/__tests__/verify.test.ts diff --git a/lib/competition/__tests__/verify.test.ts b/lib/competition/__tests__/verify.test.ts new file mode 100644 index 00000000..822e7389 --- /dev/null +++ b/lib/competition/__tests__/verify.test.ts @@ -0,0 +1,327 @@ +/** + * Tests for lib/competition/verify.ts + * + * Phase 3.1 PR-B — exercises the verifyAndPersistSwap pipeline end-to-end + * with mocked Hiro fetch + mocked D1 statements. The parser has its own + * dedicated tests (parse.test.ts) so we focus on the verifier's gating + * logic and persistence shape here. + * + * Covered paths: + * - Hiro returns 404 → tx_not_found rejection (no D1 work) + * - Hiro fetch fails / non-2xx → tx_fetch_failed rejection + * - tx_status='pending' → { status: 'pending' } (no row written) + * - Sender not in registered_wallets → sender_not_registered rejection + * - Contract+function not allowlisted → contract_not_allowlisted rejection + * - Happy path: INSERT OR IGNORE writes row, returns verified+inserted + * - Idempotent re-submission: row already exists → verified+inserted=false + * - INSERT OR IGNORE race (changes=0): re-read returns row with the + * winning source intact + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock Hiro fetch before importing verify.ts +vi.mock("@/lib/stacks-api-fetch", () => ({ + stacksApiFetch: vi.fn(), +})); + +import { verifyAndPersistSwap } from "../verify"; +import { stacksApiFetch } from "@/lib/stacks-api-fetch"; +import type { Mock } from "vitest"; + +const AGENT = "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE"; +const TXID = "0x46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4"; +const POOL = "SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M"; + +const STX_ASSET = "stx"; +const STSTX = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx"; + +function mockHiroResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function buildHappyTx() { + return { + tx_id: TXID, + tx_status: "success", + sender_address: AGENT, + tx_type: "contract_call", + burn_block_time: 1762547890, + contract_call: { + contract_id: `${POOL}.stableswap-stx-ststx-v-1-2`, + function_name: "swap-x-for-y", + function_args: [], + }, + events: [ + { + event_index: 0, + event_type: "stx_transfer_event", + asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1000000" }, + }, + { + event_index: 1, + event_type: "ft_transfer_event", + asset: { asset_event_type: "transfer", sender: POOL, recipient: AGENT, amount: "859839", asset_id: STSTX }, + }, + ], + }; +} + +/** + * Build a D1 mock where each db.prepare(sql) returns a statement whose + * .first()/.run() result depends on which SQL it sees. We key off the + * leading-keyword pattern so re-ordering INSERT vs SELECT in verify.ts + * doesn't break the fixture. + */ +function buildD1Mock(opts: { + registered?: boolean; + existingRow?: Record | null; + insertChanges?: number; + afterInsertRow?: Record | null; + throwOn?: "read-existing" | "sender-check" | "insert" | null; +}) { + let readExistingCalls = 0; + const prepare = vi.fn((sql: string) => { + const trimmed = sql.trim(); + + if (trimmed.startsWith("INSERT OR IGNORE INTO swaps")) { + return { + bind: () => ({ + run: () => { + if (opts.throwOn === "insert") { + return Promise.reject(new Error("insert blew up")); + } + return Promise.resolve({ meta: { changes: opts.insertChanges ?? 1 } }); + }, + }), + }; + } + + if (trimmed.startsWith("SELECT 1 AS ok FROM registered_wallets")) { + return { + bind: () => ({ + first: () => { + if (opts.throwOn === "sender-check") { + return Promise.reject(new Error("sender check blew up")); + } + return Promise.resolve(opts.registered ? { ok: 1 } : null); + }, + }), + }; + } + + if (trimmed.startsWith("SELECT") && trimmed.includes("FROM swaps") && trimmed.includes("WHERE txid")) { + return { + bind: () => ({ + first: () => { + readExistingCalls++; + if (opts.throwOn === "read-existing" && readExistingCalls === 1) { + return Promise.reject(new Error("read existing blew up")); + } + // First call → existingRow (pre-insert); subsequent calls → afterInsertRow + const row = readExistingCalls === 1 ? opts.existingRow : opts.afterInsertRow; + return Promise.resolve(row ?? null); + }, + }), + }; + } + + throw new Error(`Unmocked SQL: ${trimmed.slice(0, 80)}`); + }); + + return { prepare } as unknown as D1Database; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("verifyAndPersistSwap — Hiro failure paths", () => { + it("returns tx_not_found rejection when Hiro 404s", async () => { + (stacksApiFetch as Mock).mockResolvedValue(new Response("", { status: 404 })); + const db = buildD1Mock({}); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("tx_not_found"); + }); + + it("returns tx_fetch_failed rejection when Hiro returns 5xx (after retries)", async () => { + (stacksApiFetch as Mock).mockResolvedValue(new Response("", { status: 503 })); + const db = buildD1Mock({}); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("tx_fetch_failed"); + }); + + it("returns tx_fetch_failed rejection when stacksApiFetch throws (network down)", async () => { + (stacksApiFetch as Mock).mockRejectedValue(new Error("connection refused")); + const db = buildD1Mock({}); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("tx_fetch_failed"); + }); +}); + +describe("verifyAndPersistSwap — pending tx", () => { + it("returns { status: 'pending' } for tx_status='pending' (no row written)", async () => { + const pending = { ...buildHappyTx(), tx_status: "pending" }; + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(pending)); + const db = buildD1Mock({ registered: true }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("pending"); + }); +}); + +describe("verifyAndPersistSwap — sender + allowlist gates", () => { + it("rejects with sender_not_registered when sender is missing from registered_wallets", async () => { + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(buildHappyTx())); + const db = buildD1Mock({ registered: false }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("sender_not_registered"); + }); + + it("rejects with contract_not_allowlisted for an off-allowlist contract", async () => { + const tx = buildHappyTx(); + tx.contract_call.contract_id = "SP00000000000000000000.unknown-pool-v1"; + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(tx)); + const db = buildD1Mock({ registered: true }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("contract_not_allowlisted"); + }); + + it("rejects with contract_not_allowlisted for an allowlisted contract but wrong function", async () => { + const tx = buildHappyTx(); + tx.contract_call.function_name = "unknown-function"; + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(tx)); + const db = buildD1Mock({ registered: true }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("contract_not_allowlisted"); + }); +}); + +describe("verifyAndPersistSwap — happy path", () => { + it("inserts a new swap row and returns verified + inserted:true", async () => { + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(buildHappyTx())); + const db = buildD1Mock({ registered: true, insertChanges: 1 }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("verified"); + if (result.status !== "verified") return; + expect(result.inserted).toBe(true); + expect(result.row.txid).toBe(TXID); + expect(result.row.sender).toBe(AGENT); + expect(result.row.token_in).toBe(STX_ASSET); + expect(result.row.amount_in).toBe(1000000); + expect(result.row.token_out).toBe(STSTX); + expect(result.row.amount_out).toBe(859839); + expect(result.row.source).toBe("agent"); + expect(result.row.scored_value).toBeNull(); + }); + + it("propagates the source value into the persisted row", async () => { + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(buildHappyTx())); + const db = buildD1Mock({ registered: true, insertChanges: 1 }); + const result = await verifyAndPersistSwap({}, db, TXID, "cron"); + expect(result.status).toBe("verified"); + if (result.status !== "verified") return; + expect(result.row.source).toBe("cron"); + }); +}); + +describe("verifyAndPersistSwap — idempotent re-submission", () => { + it("returns verified+inserted:false when the row already exists (early read)", async () => { + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(buildHappyTx())); + const existing = { + txid: TXID, + sender: AGENT, + contract_id: `${POOL}.stableswap-stx-ststx-v-1-2`, + function_name: "swap-x-for-y", + token_in: "stx", + amount_in: 1000000, + token_out: STSTX, + amount_out: 859839, + burn_block_time: 1762547890, + tx_status: "success", + source: "chainhook", + scored_value: null, + scored_at: null, + }; + const db = buildD1Mock({ existingRow: existing }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("verified"); + if (result.status !== "verified") return; + expect(result.inserted).toBe(false); + // Source comes from the existing row — first writer wins, NOT overwritten. + expect(result.row.source).toBe("chainhook"); + }); + + it("re-reads canonical row after INSERT OR IGNORE no-op race", async () => { + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(buildHappyTx())); + const winner = { + txid: TXID, + sender: AGENT, + contract_id: `${POOL}.stableswap-stx-ststx-v-1-2`, + function_name: "swap-x-for-y", + token_in: "stx", + amount_in: 1000000, + token_out: STSTX, + amount_out: 859839, + burn_block_time: 1762547890, + tx_status: "success", + source: "chainhook", + scored_value: null, + scored_at: null, + }; + const db = buildD1Mock({ + registered: true, + existingRow: null, + insertChanges: 0, + afterInsertRow: winner, + }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("verified"); + if (result.status !== "verified") return; + expect(result.inserted).toBe(false); + expect(result.row.source).toBe("chainhook"); + }); +}); + +describe("verifyAndPersistSwap — D1 unavailability", () => { + it("returns db_unavailable rejection when reading existing row throws", async () => { + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(buildHappyTx())); + const db = buildD1Mock({ throwOn: "read-existing" }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("db_unavailable"); + }); + + it("returns db_unavailable rejection when sender check throws", async () => { + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(buildHappyTx())); + const db = buildD1Mock({ throwOn: "sender-check" }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("db_unavailable"); + }); + + it("returns db_unavailable rejection when INSERT throws", async () => { + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(buildHappyTx())); + const db = buildD1Mock({ registered: true, throwOn: "insert" }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("db_unavailable"); + }); +}); From dec3180d526c17808e4b7a2f9fd6854723147f6b Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:55:41 +0545 Subject: [PATCH 17/56] test(competition): route-level tests for POST /api/competition/trades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-B — 15 tests over the route layer with mocked verifyAndPersistSwap. Separates route-layer concerns (input validation, rate limit, KV pending tracker, response mapping) from verifier-layer concerns (Hiro fetch, D1 gates, parse) which are unit-tested separately in verify.test.ts. Covered paths: - 400 on non-JSON body, missing txid, malformed (non-hex) txid - Both 0x-prefixed and bare-hex txids accepted; handler normalises to 0x-prefixed before calling the verifier - 429 + Retry-After when RATE_LIMIT_MUTATING.limit() returns success:false - 503 + Retry-After when the D1 binding is missing entirely - 202 short-circuit when comp:pending:{txid} exists in KV (verify is NOT called — keeps repeat-poll cost low) - 202 + KV write of the 30-min pending marker when verify returns { status: 'pending' } - 200 + canonical SwapRow on verify { status: 'verified' }; pending KV marker is cleared on success - 422 + retryable:false on sender_not_registered, contract_not_allowlisted - 404 on tx_not_found - 502 + Retry-After on tx_fetch_failed (retryable:true) - 503 + Retry-After on db_unavailable Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/post-verifier.test.ts | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 app/api/competition/__tests__/post-verifier.test.ts diff --git a/app/api/competition/__tests__/post-verifier.test.ts b/app/api/competition/__tests__/post-verifier.test.ts new file mode 100644 index 00000000..da9ce2ee --- /dev/null +++ b/app/api/competition/__tests__/post-verifier.test.ts @@ -0,0 +1,264 @@ +/** + * Tests for POST /api/competition/trades — Phase 3.1 PR-B route layer. + * + * The verifier itself (lib/competition/verify.ts) has its own unit tests + * with mocked Hiro + D1. Here we exercise the *route's* responsibilities: + * + * - 400 on malformed body / bad txid + * - 429 + Retry-After when RATE_LIMIT_MUTATING trips + * - 503 + Retry-After when D1 binding is missing + * - 202 + Retry-After when KV pending marker exists (short-circuit) + * - 202 + KV write when verify returns pending + * - 200 + row when verify returns verified + * - 422 on verifier rejections (sender/allowlist/parse) + * - 404 on tx_not_found + * - 502 on tx_fetch_failed (Retry-After hint) + * - 503 on db_unavailable + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { NextRequest } from "next/server"; + +// ---- mocks ------------------------------------------------------------------ + +vi.mock("@opennextjs/cloudflare", () => ({ + getCloudflareContext: vi.fn(), +})); + +vi.mock("@/lib/logging", () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + createConsoleLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + isLogsRPC: () => false, +})); + +vi.mock("@/lib/competition/verify", () => ({ + verifyAndPersistSwap: vi.fn(), +})); + +import { POST } from "../trades/route"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { verifyAndPersistSwap } from "@/lib/competition/verify"; + +const TXID_RAW = "46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4"; +const TXID = `0x${TXID_RAW}`; + +function buildRequest(body: unknown): NextRequest { + return new NextRequest("https://aibtc.com/api/competition/trades", { + method: "POST", + headers: { "content-type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +function makeKv(overrides: Partial = {}) { + return { + get: vi.fn().mockResolvedValue(null), + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as KVNamespace; +} + +function mockEnv( + opts: { allowLimit?: boolean; omitDb?: boolean; kv?: KVNamespace } = {} +) { + const { allowLimit = true, omitDb = false, kv = makeKv() } = opts; + const db = omitDb ? undefined : ({ prepare: vi.fn() } as unknown as D1Database); + (getCloudflareContext as Mock).mockReturnValue({ + env: { + DB: db, + VERIFIED_AGENTS: kv, + RATE_LIMIT_MUTATING: { limit: vi.fn().mockResolvedValue({ success: allowLimit }) }, + LOGS: undefined, + }, + ctx: { waitUntil: vi.fn() }, + }); + return { kv }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/competition/trades — input validation", () => { + it("returns 400 on non-JSON body", async () => { + mockEnv(); + const res = await POST(buildRequest("not-json")); + expect(res.status).toBe(400); + }); + + it("returns 400 on missing txid", async () => { + mockEnv(); + const res = await POST(buildRequest({})); + expect(res.status).toBe(400); + }); + + it("returns 400 on malformed (non-hex) txid", async () => { + mockEnv(); + const res = await POST(buildRequest({ txid: "not-a-tx" })); + expect(res.status).toBe(400); + }); + + it("accepts both 0x-prefixed and bare-hex txids and normalizes", async () => { + mockEnv(); + (verifyAndPersistSwap as Mock).mockResolvedValue({ status: "pending" }); + const res = await POST(buildRequest({ txid: TXID_RAW })); + expect(res.status).toBe(202); + // The handler passed the normalized 0x-prefixed form into verify. + const passedTxid = (verifyAndPersistSwap as Mock).mock.calls[0][2]; + expect(passedTxid).toBe(TXID); + }); +}); + +describe("POST /api/competition/trades — rate limit + binding gates", () => { + it("returns 429 + Retry-After when RATE_LIMIT_MUTATING rejects", async () => { + mockEnv({ allowLimit: false }); + const res = await POST(buildRequest({ txid: TXID })); + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBeTruthy(); + }); + + it("returns 503 + Retry-After when DB binding is missing", async () => { + mockEnv({ omitDb: true }); + const res = await POST(buildRequest({ txid: TXID })); + expect(res.status).toBe(503); + expect(res.headers.get("Retry-After")).toBe("5"); + }); +}); + +describe("POST /api/competition/trades — pending tracker (KV)", () => { + it("short-circuits to 202 when comp:pending:{txid} exists in KV (no Hiro fetch)", async () => { + const kv = makeKv({ get: vi.fn().mockResolvedValue("1") }); + mockEnv({ kv }); + const res = await POST(buildRequest({ txid: TXID })); + expect(res.status).toBe(202); + const body = await res.json(); + expect(body).toEqual({ accepted: true }); + expect(verifyAndPersistSwap).not.toHaveBeenCalled(); + }); + + it("writes the pending KV marker when verify returns pending", async () => { + const kv = makeKv(); + mockEnv({ kv }); + (verifyAndPersistSwap as Mock).mockResolvedValue({ status: "pending" }); + const res = await POST(buildRequest({ txid: TXID })); + expect(res.status).toBe(202); + expect((kv.put as Mock).mock.calls[0][0]).toBe(`comp:pending:${TXID}`); + expect((kv.put as Mock).mock.calls[0][2]).toEqual({ expirationTtl: 30 * 60 }); + }); + + it("clears the pending KV marker on a verified result", async () => { + const kv = makeKv(); + mockEnv({ kv }); + (verifyAndPersistSwap as Mock).mockResolvedValue({ + status: "verified", + inserted: true, + row: { + txid: TXID, + sender: "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", + contract_id: "x", + function_name: "y", + token_in: "stx", + amount_in: 1, + token_out: "stx", + amount_out: 1, + burn_block_time: 1, + tx_status: "success", + source: "agent", + scored_value: null, + scored_at: null, + }, + }); + const res = await POST(buildRequest({ txid: TXID })); + expect(res.status).toBe(200); + expect(kv.delete).toHaveBeenCalledWith(`comp:pending:${TXID}`); + }); +}); + +describe("POST /api/competition/trades — verify result → HTTP mapping", () => { + it("returns 200 with the swap row on verified", async () => { + mockEnv(); + const row = { + txid: TXID, + sender: "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", + contract_id: "SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M.stableswap-stx-ststx-v-1-2", + function_name: "swap-x-for-y", + token_in: "stx", + amount_in: 1000000, + token_out: "ststx", + amount_out: 859839, + burn_block_time: 1762547890, + tx_status: "success", + source: "agent", + scored_value: null, + scored_at: null, + }; + (verifyAndPersistSwap as Mock).mockResolvedValue({ status: "verified", inserted: true, row }); + const res = await POST(buildRequest({ txid: TXID })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual(row); + }); + + it("returns 422 on sender_not_registered", async () => { + mockEnv(); + (verifyAndPersistSwap as Mock).mockResolvedValue({ + status: "rejected", + code: "sender_not_registered", + reason: "Sender SP… is not in registered_wallets", + }); + const res = await POST(buildRequest({ txid: TXID })); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.code).toBe("sender_not_registered"); + expect(body.retryable).toBe(false); + }); + + it("returns 422 on contract_not_allowlisted", async () => { + mockEnv(); + (verifyAndPersistSwap as Mock).mockResolvedValue({ + status: "rejected", + code: "contract_not_allowlisted", + reason: "off-allowlist", + }); + const res = await POST(buildRequest({ txid: TXID })); + expect(res.status).toBe(422); + }); + + it("returns 404 on tx_not_found", async () => { + mockEnv(); + (verifyAndPersistSwap as Mock).mockResolvedValue({ + status: "rejected", + code: "tx_not_found", + reason: "Hiro 404", + }); + const res = await POST(buildRequest({ txid: TXID })); + expect(res.status).toBe(404); + }); + + it("returns 502 + Retry-After on tx_fetch_failed", async () => { + mockEnv(); + (verifyAndPersistSwap as Mock).mockResolvedValue({ + status: "rejected", + code: "tx_fetch_failed", + reason: "Hiro 503", + }); + const res = await POST(buildRequest({ txid: TXID })); + expect(res.status).toBe(502); + expect(res.headers.get("Retry-After")).toBe("5"); + const body = await res.json(); + expect(body.retryable).toBe(true); + }); + + it("returns 503 + Retry-After on db_unavailable", async () => { + mockEnv(); + (verifyAndPersistSwap as Mock).mockResolvedValue({ + status: "rejected", + code: "db_unavailable", + reason: "D1 read failed", + }); + const res = await POST(buildRequest({ txid: TXID })); + expect(res.status).toBe(503); + expect(res.headers.get("Retry-After")).toBe("5"); + }); +}); From ffddd4bc6f841142de85a22630053908052c32e2 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:58:10 +0545 Subject: [PATCH 18/56] feat(competition): chainhook payload validation + HMAC auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-C — helpers consumed by app/api/competition/chainhook to receive predicate firings from a Hiro chainhook controller (or a self-hosted controller). parseChainhookPayload: - Tolerant of both txid shapes (entry-level `txid` AND nested `transaction.transaction_identifier.hash` — the Hiro envelope location shifts between controller versions). - Normalises bare-hex txids to 0x-prefixed. - Dedupes repeats so a batched-multi-event-per-tx firing doesn't cause N verifier invocations for the same row. - Rollback entries are deliberately ignored. The verifier persists only terminal-status rows; if a row is rolled back the future canonical apply will write a fresh row with a different txid, and the historical row stays in `swaps` as audit (scoring queries can filter on tx_status as needed). extractChainhookSignature: pulls hex digest from either `X-Chainhook-Signature` (preferred) or `Authorization: Bearer …` (Hiro controller default). verifyChainhookSignature: HMAC-SHA256 over the request body using CHAINHOOK_SECRET, compared via the same two-layer HMAC constant-time trick used in lib/admin/auth.ts. Predicate registration against the contracts in lib/competition/allowlist.ts is OUT OF SCOPE — that's controller config, not landing-page code; tracked as a follow-up. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/chainhook.ts | 158 +++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 lib/competition/chainhook.ts diff --git a/lib/competition/chainhook.ts b/lib/competition/chainhook.ts new file mode 100644 index 00000000..1b6d7672 --- /dev/null +++ b/lib/competition/chainhook.ts @@ -0,0 +1,158 @@ +/** + * Chainhook payload validation + HMAC auth for the trading-comp verifier. + * + * Phase 3.1 PR-C — receives chainhook predicate firings from Hiro's + * controller (or our own self-hosted controller). The chainhook payload + * is a JSON envelope with `apply` (rolled-forward txs) and `rollback` + * (rolled-back txs) arrays; for the trading-comp surface we only care + * about `apply`. Each apply entry contains a transaction the predicate + * matched against — we hand each one to verifyAndPersistSwap with + * source='chainhook'. + * + * Auth: HMAC over the request body using `env.CHAINHOOK_SECRET`. The + * controller computes HMAC-SHA256(body) and sends it as either: + * - `Authorization: Bearer {hex}` (hiro chainhook controller format) + * - `X-Chainhook-Signature: {hex}` (our convenience header) + * + * Predicate registration is OUT OF SCOPE of this PR. The chainhook needs + * to be registered against the contracts in lib/competition/allowlist.ts — + * that's a follow-up because it requires controller config rather than + * landing-page changes. + * + * See: app/api/competition/chainhook/route.ts (transport layer). + */ + +export interface ChainhookApplyEntry { + /** The transaction body — we read tx_id from it and re-fetch via Hiro. */ + transaction?: { + transaction_identifier?: { hash?: string }; + }; + /** Some chainhook payloads include the tx hash at the entry level. */ + txid?: string; +} + +export interface ChainhookPayload { + apply?: ChainhookApplyEntry[] | unknown; + rollback?: unknown; +} + +/** Result of payload validation. */ +export type ParseChainhookResult = + | { ok: true; txids: string[] } + | { ok: false; reason: string }; + +const TXID_RE = /^(0x)?[0-9a-fA-F]{64}$/; + +function normalizeTxid(raw: unknown): string | null { + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + if (!TXID_RE.test(trimmed)) return null; + return trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`; +} + +/** + * Pull the txid list out of a chainhook payload. + * + * Tolerant of both payload shapes (hash at entry-level OR nested inside + * transaction.transaction_identifier) so we don't break if Hiro's + * envelope shifts between controller versions. + * + * Rollback entries are deliberately ignored: a rollback means "this tx + * is no longer canonical", not "this tx happened". The verifier + * persists only terminal-status rows; if a row is rolled back, a future + * apply with the new canonical txid will write a fresh row (different + * txid → different PK). The original row stays in `swaps` as historical + * audit (scoring queries can filter on tx_status if needed). + */ +export function parseChainhookPayload(payload: unknown): ParseChainhookResult { + if (typeof payload !== "object" || payload === null) { + return { ok: false, reason: "Payload is not a JSON object" }; + } + const obj = payload as ChainhookPayload; + const apply = obj.apply; + if (apply === undefined) { + return { ok: false, reason: "Payload missing required `apply` field" }; + } + if (!Array.isArray(apply)) { + return { ok: false, reason: "`apply` must be an array" }; + } + + const txids: string[] = []; + for (const entry of apply as ChainhookApplyEntry[]) { + if (typeof entry !== "object" || entry === null) continue; + const fromTop = normalizeTxid(entry.txid); + if (fromTop) { + txids.push(fromTop); + continue; + } + const fromNested = normalizeTxid(entry.transaction?.transaction_identifier?.hash); + if (fromNested) { + txids.push(fromNested); + } + } + // Dedupe — chainhook controllers occasionally batch the same txid twice + // when the predicate matches multiple events on one tx. + return { ok: true, txids: Array.from(new Set(txids)) }; +} + +/** Compute HMAC-SHA256(secret, body) and return the lowercase hex digest. */ +export async function computeChainhookSignature( + body: string, + secret: string +): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body)); + return [...new Uint8Array(sig)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Extract the signature from either `Authorization: Bearer …` or + * `X-Chainhook-Signature: …`. Returns lowercase hex or null. + */ +export function extractChainhookSignature(headers: Headers): string | null { + const explicit = headers.get("x-chainhook-signature"); + if (explicit) return explicit.trim().toLowerCase(); + const auth = headers.get("authorization"); + if (auth?.toLowerCase().startsWith("bearer ")) { + return auth.slice(7).trim().toLowerCase(); + } + return null; +} + +/** + * Constant-time HMAC compare. Both sides are hashed under a fixed key + * before the equality check so the comparison runs in deterministic time + * regardless of input mismatch. + */ +export async function verifyChainhookSignature( + body: string, + providedSig: string, + secret: string +): Promise { + const expected = await computeChainhookSignature(body, secret); + // Compare via second HMAC layer (same trick as lib/admin/auth.ts) + const encoder = new TextEncoder(); + const compareKey = await crypto.subtle.importKey( + "raw", + encoder.encode("chainhook-compare"), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const [a, b] = await Promise.all([ + crypto.subtle.sign("HMAC", compareKey, encoder.encode(expected)), + crypto.subtle.sign("HMAC", compareKey, encoder.encode(providedSig)), + ]); + const hexA = [...new Uint8Array(a)].map((x) => x.toString(16).padStart(2, "0")).join(""); + const hexB = [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join(""); + return hexA === hexB; +} From c06f29b6dddc9193839a84495fc8ae208d288850 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:58:20 +0545 Subject: [PATCH 19/56] feat(competition): chainhook ingestion route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-C — POST /api/competition/chainhook receives predicate firings and dispatches each tx in `apply` to verifyAndPersistSwap with source='chainhook'. Re-uses the same verifier as POST /api/ competition/trades; only the source label and the auth surface differ. Flow: 1. Lookup CHAINHOOK_SECRET (500 if not configured) 2. Extract signature from X-Chainhook-Signature or Authorization Bearer (401 if absent) 3. Read raw body, HMAC-verify against secret (401 on mismatch) 4. JSON.parse body (400 on parse failure) 5. parseChainhookPayload → list of normalised, deduped txids 6. Check DB binding (503 if missing) 7. For each txid: verifyAndPersistSwap, tally inserted/alreadyKnown/pending/rejected for the response summary 8. Return 200 with { processed, inserted, alreadyKnown, rejected, pending } — the controller treats 200 as ack-and-don't-retry. Cache-Control: no-store on every response shape so signed payloads are never cached by intermediate proxies. GET returns a self-doc payload so an operator hitting the URL gets a description of the HMAC scheme + response matrix without hunting through docs. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/competition/chainhook/route.ts | 160 +++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 app/api/competition/chainhook/route.ts diff --git a/app/api/competition/chainhook/route.ts b/app/api/competition/chainhook/route.ts new file mode 100644 index 00000000..295f7a25 --- /dev/null +++ b/app/api/competition/chainhook/route.ts @@ -0,0 +1,160 @@ +// CACHE_INVARIANTS:POSTURE=auth-required +// This route accepts chainhook predicate firings from Hiro's controller +// (or a self-hosted controller). HMAC-authenticated; no public cache. + +import { NextRequest, NextResponse } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { createLogger, createConsoleLogger, isLogsRPC } from "@/lib/logging"; +import { + parseChainhookPayload, + extractChainhookSignature, + verifyChainhookSignature, +} from "@/lib/competition/chainhook"; +import { verifyAndPersistSwap } from "@/lib/competition/verify"; + +interface IngestSummary { + scanned: number; + inserted: number; + alreadyKnown: number; + rejected: number; + pending: number; +} + +export async function GET() { + return NextResponse.json( + { + endpoint: "/api/competition/chainhook", + method: "POST", + description: + "Receives Hiro chainhook predicate firings for the trading competition. HMAC-authenticated via CHAINHOOK_SECRET. Each tx in `apply` is handed to verifyAndPersistSwap with source='chainhook'. Rollback entries are ignored (the verifier persists only terminal-status rows; rolled-back txs simply never replay).", + auth: { + scheme: "HMAC-SHA256", + header: "Authorization: Bearer {hex} (or X-Chainhook-Signature: {hex})", + body: "HMAC-SHA256(env.CHAINHOOK_SECRET, request_body)", + }, + response: { + "200": { processed: "number", inserted: "number", rejected: "number" }, + "401": "Missing or invalid signature", + "400": "Malformed JSON payload", + "503": "D1 unavailable — retry", + }, + notes: [ + "Predicate registration is OUT OF SCOPE of this route; configure the chainhook controller against the contracts in lib/competition/allowlist.ts.", + "Source enum: this route always writes source='chainhook'. First-writer-wins on (txid).", + ], + }, + { headers: { "Cache-Control": "no-store" } } + ); +} + +export async function POST(request: NextRequest) { + const { env, ctx } = await getCloudflareContext(); + const rayId = request.headers.get("cf-ray") || crypto.randomUUID(); + const logger = isLogsRPC(env.LOGS) + ? createLogger(env.LOGS, ctx, { rayId, path: request.nextUrl.pathname }) + : createConsoleLogger({ rayId, path: request.nextUrl.pathname }); + + const secret = env.CHAINHOOK_SECRET; + if (!secret) { + logger.error("CHAINHOOK_SECRET not configured"); + return NextResponse.json( + { error: "Server configuration error" }, + { status: 500, headers: { "Cache-Control": "no-store" } } + ); + } + + const providedSig = extractChainhookSignature(request.headers); + if (!providedSig) { + return NextResponse.json( + { error: "Missing chainhook signature (Authorization: Bearer … or X-Chainhook-Signature)" }, + { status: 401, headers: { "Cache-Control": "no-store" } } + ); + } + + // Read raw body once — both signature verify and JSON parse need it. + const rawBody = await request.text(); + + const sigValid = await verifyChainhookSignature(rawBody, providedSig, secret); + if (!sigValid) { + logger.warn("Chainhook signature mismatch"); + return NextResponse.json( + { error: "Invalid chainhook signature" }, + { status: 401, headers: { "Cache-Control": "no-store" } } + ); + } + + let payload: unknown; + try { + payload = JSON.parse(rawBody); + } catch { + return NextResponse.json( + { error: "Body is not valid JSON" }, + { status: 400, headers: { "Cache-Control": "no-store" } } + ); + } + + const parseRes = parseChainhookPayload(payload); + if (!parseRes.ok) { + return NextResponse.json( + { error: parseRes.reason }, + { status: 400, headers: { "Cache-Control": "no-store" } } + ); + } + + const db = env.DB as D1Database | undefined; + if (!db) { + logger.warn("D1 binding missing on competition/chainhook POST"); + return NextResponse.json( + { + error: "transient_d1_unavailable", + message: "Competition database temporarily unavailable. Please retry shortly.", + retry_after: 5, + }, + { status: 503, headers: { "Retry-After": "5", "Cache-Control": "no-store" } } + ); + } + + const summary: IngestSummary = { + scanned: parseRes.txids.length, + inserted: 0, + alreadyKnown: 0, + rejected: 0, + pending: 0, + }; + + // Process txids serially. Chainhook batches are small (predicate firings + // per block) and serial processing keeps Hiro rate-limit exposure simple; + // verifyAndPersistSwap already handles its own retries. + for (const txid of parseRes.txids) { + try { + const result = await verifyAndPersistSwap(env, db, txid, "chainhook", logger); + if (result.status === "verified") { + if (result.inserted) summary.inserted++; + else summary.alreadyKnown++; + } else if (result.status === "pending") { + summary.pending++; + } else { + summary.rejected++; + logger.info("Chainhook txid rejected", { + txid, + code: result.code, + reason: result.reason, + }); + } + } catch (err) { + summary.rejected++; + logger.warn("Chainhook verify threw", { txid, error: String(err) }); + } + } + + return NextResponse.json( + { + processed: summary.scanned, + inserted: summary.inserted, + alreadyKnown: summary.alreadyKnown, + rejected: summary.rejected, + pending: summary.pending, + }, + { status: 200, headers: { "Cache-Control": "no-store" } } + ); +} From cd7f158435a4ddcfd62c39994ce55d58ab73deb5 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:58:29 +0545 Subject: [PATCH 20/56] test(competition): unit tests for chainhook helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-C — 17 tests over payload parsing + HMAC verify. parseChainhookPayload: - Rejects null/non-object payloads - Rejects missing or non-array `apply` - Extracts txids from both entry-level `txid` and nested transaction.transaction_identifier.hash shapes - Normalises bare-hex to 0x-prefixed - Dedupes repeated txids across shapes - Ignores malformed entries without rejecting the whole batch - Accepts empty apply array as success (downstream no-op) extractChainhookSignature: - Prefers X-Chainhook-Signature when both headers are present - Falls back to Authorization: Bearer - Returns null on missing or non-Bearer schemes verifyChainhookSignature: - Round-trip: known body + secret → verify true - Wrong signature → false - Wrong secret with right body → false (attacker pre-image guard) - Case-sensitive on hex (uppercase fails; lowercase ok — extract lowercases up front, this asserts the contract) Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/__tests__/chainhook.test.ts | 151 ++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 lib/competition/__tests__/chainhook.test.ts diff --git a/lib/competition/__tests__/chainhook.test.ts b/lib/competition/__tests__/chainhook.test.ts new file mode 100644 index 00000000..ae749b43 --- /dev/null +++ b/lib/competition/__tests__/chainhook.test.ts @@ -0,0 +1,151 @@ +/** + * Tests for lib/competition/chainhook.ts + * + * Phase 3.1 PR-C — payload parsing + HMAC signature verification. + */ + +import { describe, it, expect } from "vitest"; +import { + parseChainhookPayload, + computeChainhookSignature, + extractChainhookSignature, + verifyChainhookSignature, +} from "../chainhook"; + +const TXID = "0x46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4"; +const TXID_BARE = "46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4"; + +describe("parseChainhookPayload", () => { + it("rejects null / non-object payload", () => { + expect(parseChainhookPayload(null).ok).toBe(false); + expect(parseChainhookPayload(42).ok).toBe(false); + expect(parseChainhookPayload("foo").ok).toBe(false); + }); + + it("rejects payload missing `apply`", () => { + const r = parseChainhookPayload({ rollback: [] }); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toMatch(/apply/); + }); + + it("rejects `apply` that is not an array", () => { + const r = parseChainhookPayload({ apply: "not-array" }); + expect(r.ok).toBe(false); + }); + + it("extracts txids from entry-level `txid`", () => { + const r = parseChainhookPayload({ apply: [{ txid: TXID }] }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.txids).toEqual([TXID]); + }); + + it("extracts txids from nested transaction_identifier.hash (Hiro shape)", () => { + const r = parseChainhookPayload({ + apply: [ + { transaction: { transaction_identifier: { hash: TXID } } }, + ], + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.txids).toEqual([TXID]); + }); + + it("normalizes bare-hex txids to 0x-prefixed", () => { + const r = parseChainhookPayload({ apply: [{ txid: TXID_BARE }] }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.txids).toEqual([TXID]); + }); + + it("dedupes repeated txids in a batch", () => { + const r = parseChainhookPayload({ + apply: [ + { txid: TXID }, + { txid: TXID }, + { transaction: { transaction_identifier: { hash: TXID } } }, + ], + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.txids).toEqual([TXID]); + }); + + it("ignores malformed entries (no hash anywhere) without rejecting the whole batch", () => { + const r = parseChainhookPayload({ + apply: [ + { txid: TXID }, + { foo: "bar" }, + { transaction: {} }, + ], + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.txids).toEqual([TXID]); + }); + + it("returns empty txid list for empty apply array (200, no inserts downstream)", () => { + const r = parseChainhookPayload({ apply: [] }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.txids).toEqual([]); + }); +}); + +describe("extractChainhookSignature", () => { + it("prefers X-Chainhook-Signature when both headers are present", () => { + const h = new Headers({ + "x-chainhook-signature": "ABC123", + authorization: "Bearer DEF456", + }); + expect(extractChainhookSignature(h)).toBe("abc123"); + }); + + it("falls back to Authorization: Bearer …", () => { + const h = new Headers({ authorization: "Bearer FACE" }); + expect(extractChainhookSignature(h)).toBe("face"); + }); + + it("returns null when no signature header is present", () => { + const h = new Headers({ "content-type": "application/json" }); + expect(extractChainhookSignature(h)).toBeNull(); + }); + + it("returns null on non-Bearer Authorization scheme", () => { + const h = new Headers({ authorization: "Basic abc123" }); + expect(extractChainhookSignature(h)).toBeNull(); + }); +}); + +describe("verifyChainhookSignature", () => { + it("returns true when the signature matches the body+secret HMAC", async () => { + const body = JSON.stringify({ apply: [] }); + const secret = "test-secret-key"; + const sig = await computeChainhookSignature(body, secret); + expect(await verifyChainhookSignature(body, sig, secret)).toBe(true); + }); + + it("returns false when the signature is wrong", async () => { + const body = JSON.stringify({ apply: [] }); + const secret = "test-secret-key"; + const sig = await computeChainhookSignature("DIFFERENT BODY", secret); + expect(await verifyChainhookSignature(body, sig, secret)).toBe(false); + }); + + it("returns false when the secret is wrong (attacker has body but not secret)", async () => { + const body = JSON.stringify({ apply: [] }); + const sig = await computeChainhookSignature(body, "right-secret"); + expect(await verifyChainhookSignature(body, sig, "wrong-secret")).toBe(false); + }); + + it("is case-insensitive on the signature hex (lowercases on input via extract)", async () => { + // verifyChainhookSignature itself does case-sensitive compare on the hex; + // upstream extraction lowercases. We assert the contract by lowercasing here. + const body = JSON.stringify({ apply: [] }); + const secret = "test-secret-key"; + const sig = (await computeChainhookSignature(body, secret)).toUpperCase(); + expect(await verifyChainhookSignature(body, sig, secret)).toBe(false); + expect(await verifyChainhookSignature(body, sig.toLowerCase(), secret)).toBe(true); + }); +}); From 58c5b39c4dc757a9474ef9952a0d2abc4a448e87 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 09:58:36 +0545 Subject: [PATCH 21/56] test(competition): route-level tests for POST /api/competition/chainhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-C — 10 tests over the chainhook ingestion route's auth + dispatch responsibilities. The verifier itself is unit-tested elsewhere; here we exercise the route layer. Auth gates: - 500 when CHAINHOOK_SECRET is not configured - 401 when no signature header is present - 401 when the signature does not match the body+secret HMAC - Authorization: Bearer … is accepted (Hiro controller default) Payload + dispatch: - 400 on invalid JSON - 400 when `apply` is missing - 503 + Retry-After:5 when D1 binding is missing - Each txid dispatches verifyAndPersistSwap with source='chainhook' - Response counts split inserted vs alreadyKnown vs pending vs rejected so the controller can act on the summary - Empty apply batch returns 200 with processed:0 (no spurious 4xx) Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/chainhook-route.test.ts | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 app/api/competition/__tests__/chainhook-route.test.ts diff --git a/app/api/competition/__tests__/chainhook-route.test.ts b/app/api/competition/__tests__/chainhook-route.test.ts new file mode 100644 index 00000000..c7907e6d --- /dev/null +++ b/app/api/competition/__tests__/chainhook-route.test.ts @@ -0,0 +1,195 @@ +/** + * Tests for POST /api/competition/chainhook — Phase 3.1 PR-C. + * + * Exercises the route's auth + dispatch responsibilities. The + * verifier's own logic is unit-tested in lib/competition/__tests__. + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@opennextjs/cloudflare", () => ({ + getCloudflareContext: vi.fn(), +})); +vi.mock("@/lib/logging", () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + createConsoleLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + isLogsRPC: () => false, +})); +vi.mock("@/lib/competition/verify", () => ({ + verifyAndPersistSwap: vi.fn(), +})); + +import { POST } from "../chainhook/route"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { verifyAndPersistSwap } from "@/lib/competition/verify"; +import { computeChainhookSignature } from "@/lib/competition/chainhook"; + +const SECRET = "test-chainhook-secret"; +const TXID = "0x46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4"; + +interface MockEnvOpts { + omitDb?: boolean; + omitSecret?: boolean; +} + +function mockEnv(opts: MockEnvOpts = {}) { + const db = opts.omitDb ? undefined : ({ prepare: vi.fn() } as unknown as D1Database); + (getCloudflareContext as Mock).mockReturnValue({ + env: { + DB: db, + LOGS: undefined, + ...(opts.omitSecret ? {} : { CHAINHOOK_SECRET: SECRET }), + }, + ctx: { waitUntil: vi.fn() }, + }); +} + +async function buildSignedRequest(body: unknown): Promise { + const bodyText = typeof body === "string" ? body : JSON.stringify(body); + const sig = await computeChainhookSignature(bodyText, SECRET); + return new NextRequest("https://aibtc.com/api/competition/chainhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-chainhook-signature": sig, + }, + body: bodyText, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/competition/chainhook — auth gates", () => { + it("returns 500 when CHAINHOOK_SECRET is not configured", async () => { + mockEnv({ omitSecret: true }); + const req = new NextRequest("https://aibtc.com/api/competition/chainhook", { + method: "POST", + headers: { "content-type": "application/json", "x-chainhook-signature": "abc" }, + body: "{}", + }); + const res = await POST(req); + expect(res.status).toBe(500); + }); + + it("returns 401 when no signature header is present", async () => { + mockEnv(); + const req = new NextRequest("https://aibtc.com/api/competition/chainhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + const res = await POST(req); + expect(res.status).toBe(401); + }); + + it("returns 401 when the signature is wrong", async () => { + mockEnv(); + const req = new NextRequest("https://aibtc.com/api/competition/chainhook", { + method: "POST", + headers: { + "content-type": "application/json", + "x-chainhook-signature": "0".repeat(64), + }, + body: JSON.stringify({ apply: [] }), + }); + const res = await POST(req); + expect(res.status).toBe(401); + }); + + it("accepts Authorization: Bearer …", async () => { + mockEnv(); + const body = JSON.stringify({ apply: [] }); + const sig = await computeChainhookSignature(body, SECRET); + const req = new NextRequest("https://aibtc.com/api/competition/chainhook", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${sig}`, + }, + body, + }); + const res = await POST(req); + expect(res.status).toBe(200); + }); +}); + +describe("POST /api/competition/chainhook — payload + dispatch", () => { + it("returns 400 on invalid JSON", async () => { + mockEnv(); + const body = "not-json"; + const sig = await computeChainhookSignature(body, SECRET); + const req = new NextRequest("https://aibtc.com/api/competition/chainhook", { + method: "POST", + headers: { "content-type": "application/json", "x-chainhook-signature": sig }, + body, + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 400 when apply is missing", async () => { + mockEnv(); + const res = await POST(await buildSignedRequest({ rollback: [] })); + expect(res.status).toBe(400); + }); + + it("returns 503 when DB binding is missing", async () => { + mockEnv({ omitDb: true }); + const res = await POST(await buildSignedRequest({ apply: [{ txid: TXID }] })); + expect(res.status).toBe(503); + expect(res.headers.get("Retry-After")).toBe("5"); + }); + + it("dispatches each txid to verifyAndPersistSwap with source='chainhook'", async () => { + mockEnv(); + (verifyAndPersistSwap as Mock).mockResolvedValue({ + status: "verified", + inserted: true, + row: { txid: TXID } as unknown, + }); + const res = await POST(await buildSignedRequest({ apply: [{ txid: TXID }] })); + expect(res.status).toBe(200); + expect(verifyAndPersistSwap).toHaveBeenCalledTimes(1); + expect((verifyAndPersistSwap as Mock).mock.calls[0][3]).toBe("chainhook"); + const body = await res.json(); + expect(body.processed).toBe(1); + expect(body.inserted).toBe(1); + }); + + it("counts already-known vs newly-inserted vs pending vs rejected", async () => { + mockEnv(); + (verifyAndPersistSwap as Mock) + .mockResolvedValueOnce({ status: "verified", inserted: true, row: {} }) + .mockResolvedValueOnce({ status: "verified", inserted: false, row: {} }) + .mockResolvedValueOnce({ status: "pending" }) + .mockResolvedValueOnce({ status: "rejected", code: "sender_not_registered", reason: "x" }); + const apply = [ + { txid: "0x" + "a".repeat(64) }, + { txid: "0x" + "b".repeat(64) }, + { txid: "0x" + "c".repeat(64) }, + { txid: "0x" + "d".repeat(64) }, + ]; + const res = await POST(await buildSignedRequest({ apply })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchObject({ + processed: 4, + inserted: 1, + alreadyKnown: 1, + pending: 1, + rejected: 1, + }); + }); + + it("returns 200 with processed:0 on an empty apply batch (no spurious 4xx)", async () => { + mockEnv(); + const res = await POST(await buildSignedRequest({ apply: [] })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.processed).toBe(0); + expect(verifyAndPersistSwap).not.toHaveBeenCalled(); + }); +}); From 79c6190e81d037cb073fbc650c87dc559cc5b703 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:01:38 +0545 Subject: [PATCH 22/56] feat(competition): nightly catch-up cron sweep helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-D — runCompetitionCron walks registered_wallets, fetches recent Hiro tx history per address, filters by allowlist, and submits each match via verifyAndPersistSwap with source='cron'. Defence in depth: chainhook may drop firings (controller down, predicate missed an event); the next cron sweep re-fetches Hiro history and the INSERT OR IGNORE shape makes the second-arrival idempotent. The handoff: > Each ingestion path converges on the same swaps table via (txid) > INSERT OR IGNORE. First writer wins. Cost shape: - Per-run cap: CRON_MAX_ADDRESSES_PER_RUN = 100. The sweep resumes across runs via the comp:cron:cursor KV key. - Address page is fetched with WHERE stx_address > ? ORDER BY ASC so the cursor is monotonic and stable under concurrent registrations. - When the page is partial (walk wrapped), the cursor is deleted so the next run starts at the head — no infinite no-op loop. The address-history fetcher (Hiro extended/v1/address/{principal}/ transactions) is injectable for tests; production calls the real stacksApiFetch with the standard retry+backoff config. Fault tolerance: a verify() throw is counted as rejected and the sweep continues — one bad tx doesn't abort the run. Wrangler scheduled-trigger wiring is tracked as a follow-up: OpenNext-cloudflare's worker entry doesn't auto-bridge `crons:` to Next.js API routes, so the route is currently invoked via HTTPS with the shared secret from an external scheduler (or a small cron-trigger Worker calling our URL). Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/cron.ts | 188 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 lib/competition/cron.ts diff --git a/lib/competition/cron.ts b/lib/competition/cron.ts new file mode 100644 index 00000000..08619dc3 --- /dev/null +++ b/lib/competition/cron.ts @@ -0,0 +1,188 @@ +/** + * Nightly catch-up cron — walks registered_wallets and re-verifies recent + * Hiro tx history. Phase 3.1 PR-D. + * + * Each ingestion path converges on the same `swaps` row via INSERT OR IGNORE + * on `txid`. The cron's job is *defence in depth* against chainhook gaps: + * if the chainhook controller went down or a predicate missed a firing, + * the next cron sweep will pick the tx up and persist it. + * + * Cost shape (handoff caps): + * - Max 100 addresses per execution + * - Resume from KV cursor `comp:cron:cursor` so subsequent runs continue + * where the previous one stopped, walking the whole membership list + * across multiple cron firings rather than retrying the same head N + * times. + * + * Returns a structured summary for the logs: + * { scanned, found, inserted, alreadyKnown, rejected, pending, cursor } + * + * The wrangler scheduled trigger registration (and the bridge from the + * Worker's scheduled() entrypoint to this code) is infrastructure wiring + * tracked as a follow-up — the route is callable directly via HTTPS with + * a shared-secret header for now. + */ + +import type { Logger } from "@/lib/logging"; +import { stacksApiFetch } from "@/lib/stacks-api-fetch"; +import { STACKS_API_BASE } from "@/lib/identity/constants"; +import { verifyAndPersistSwap } from "./verify"; +import { isAllowedSwap } from "./allowlist"; + +/** Per-run cap on addresses scanned. Cron resumes from the KV cursor next run. */ +export const CRON_MAX_ADDRESSES_PER_RUN = 100; +export const CRON_CURSOR_KV_KEY = "comp:cron:cursor"; + +/** Per-address tx history page size. */ +const HIRO_TX_PAGE_LIMIT = 25; + +export interface CronSummary { + scanned: number; + found: number; + inserted: number; + alreadyKnown: number; + rejected: number; + pending: number; + /** Next address (stx_address) to resume from, or null if the walk wrapped. */ + cursor: string | null; +} + +interface AddressTxEntry { + tx_id?: string; + tx_type?: string; + contract_call?: { + contract_id?: string; + function_name?: string; + }; +} + +async function fetchAddressTxs( + env: { HIRO_API_KEY?: string }, + stxAddress: string, + logger?: Logger +): Promise { + const url = `${STACKS_API_BASE}/extended/v1/address/${stxAddress}/transactions?limit=${HIRO_TX_PAGE_LIMIT}`; + const headers: Record = { Accept: "application/json" }; + if (env.HIRO_API_KEY) headers["x-hiro-api-key"] = env.HIRO_API_KEY; + + try { + const response = await stacksApiFetch(url, { method: "GET", headers }, { logger }); + if (!response.ok) { + logger?.warn?.("competition.cron.hiro_non_ok", { + stxAddress, + status: response.status, + }); + return []; + } + const body = (await response.json()) as { results?: AddressTxEntry[] }; + return body.results ?? []; + } catch (err) { + logger?.warn?.("competition.cron.hiro_threw", { + stxAddress, + error: String(err), + }); + return []; + } +} + +/** + * Page through registered_wallets starting from the cursor. Returns up to + * CRON_MAX_ADDRESSES_PER_RUN rows ordered by stx_address ASC so the cursor + * is monotonic. When the walk wraps (no more rows after cursor), the next + * call returns the head of the list and the cursor resets to null. + */ +async function fetchAddressPage( + db: D1Database, + cursor: string | null +): Promise<{ rows: { stx_address: string }[]; nextCursor: string | null }> { + const sql = cursor + ? `SELECT stx_address FROM registered_wallets WHERE stx_address > ?1 ORDER BY stx_address ASC LIMIT ?2` + : `SELECT stx_address FROM registered_wallets ORDER BY stx_address ASC LIMIT ?1`; + const stmt = cursor + ? db.prepare(sql).bind(cursor, CRON_MAX_ADDRESSES_PER_RUN) + : db.prepare(sql).bind(CRON_MAX_ADDRESSES_PER_RUN); + const result = await stmt.all<{ stx_address: string }>(); + const rows = result.results ?? []; + let nextCursor: string | null = null; + if (rows.length === CRON_MAX_ADDRESSES_PER_RUN) { + nextCursor = rows[rows.length - 1].stx_address; + } + return { rows, nextCursor }; +} + +export interface RunCronOptions { + /** Override the KV cursor key. Used by tests for isolation. */ + cursorKey?: string; + /** Inject a custom address-history fetcher (for tests). */ + fetchAddressTxsImpl?: typeof fetchAddressTxs; +} + +/** + * Execute one cron sweep. + * + * The handoff: walk registered_wallets, fetch recent Hiro history per + * address, filter by allowlist, submit each match via verifyAndPersistSwap + * with source='cron'. The KV cursor lets the sweep resume across runs + * rather than always starting at the head. + */ +export async function runCompetitionCron( + env: { DB: D1Database; VERIFIED_AGENTS: KVNamespace; HIRO_API_KEY?: string }, + logger?: Logger, + options: RunCronOptions = {} +): Promise { + const cursorKey = options.cursorKey ?? CRON_CURSOR_KV_KEY; + const txsFetcher = options.fetchAddressTxsImpl ?? fetchAddressTxs; + + const cursor = (await env.VERIFIED_AGENTS.get(cursorKey)) ?? null; + const { rows, nextCursor } = await fetchAddressPage(env.DB, cursor); + + const summary: CronSummary = { + scanned: rows.length, + found: 0, + inserted: 0, + alreadyKnown: 0, + rejected: 0, + pending: 0, + cursor: nextCursor, + }; + + for (const { stx_address } of rows) { + const txs = await txsFetcher(env, stx_address, logger); + for (const tx of txs) { + if (tx.tx_type !== "contract_call") continue; + if (!tx.contract_call?.contract_id || !tx.contract_call.function_name) continue; + if (!isAllowedSwap(tx.contract_call.contract_id, tx.contract_call.function_name)) continue; + if (!tx.tx_id) continue; + summary.found++; + try { + const result = await verifyAndPersistSwap(env, env.DB, tx.tx_id, "cron", logger); + if (result.status === "verified") { + if (result.inserted) summary.inserted++; + else summary.alreadyKnown++; + } else if (result.status === "pending") { + summary.pending++; + } else { + summary.rejected++; + } + } catch (err) { + summary.rejected++; + logger?.warn?.("competition.cron.verify_threw", { + stxAddress: stx_address, + txid: tx.tx_id, + error: String(err), + }); + } + } + } + + // Persist next cursor. When nextCursor is null (we walked the tail), + // delete the key so the next run starts fresh at the head. + if (nextCursor) { + await env.VERIFIED_AGENTS.put(cursorKey, nextCursor); + } else { + await env.VERIFIED_AGENTS.delete(cursorKey); + } + + logger?.info?.("competition.cron.summary", { ...summary }); + return summary; +} From ddffffac0b9db8c9c0cf8cacff2167ba21cd7ab7 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:01:47 +0545 Subject: [PATCH 23/56] feat(competition): cron catch-up route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-D — POST /api/competition/cron is the HTTPS entry point for the nightly catch-up sweep. Shared-secret authenticated via X-Cron-Secret (matches env.CRON_SECRET). The route is a thin transport layer over runCompetitionCron; all walk + dispatch logic lives in lib/competition/cron.ts. Response is the cron summary { scanned, found, inserted, alreadyKnown, pending, rejected, cursor } so the caller (Cloudflare cron trigger, external scheduler, ops sanity-check) can see the result of the run at a glance. Cache-Control: no-store on every shape — the summary is per-run state, never reusable. GET returns a self-doc payload (auth scheme, response shape, cost cap, KV-cursor mechanic). Useful when an operator pokes the URL without running the cron. The current Retry-After on 503 is 60s — cron is infrequent so a minute is a reasonable client-side delay before re-trying when the DB is briefly unavailable. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/competition/cron/route.ts | 82 +++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 app/api/competition/cron/route.ts diff --git a/app/api/competition/cron/route.ts b/app/api/competition/cron/route.ts new file mode 100644 index 00000000..07427cb7 --- /dev/null +++ b/app/api/competition/cron/route.ts @@ -0,0 +1,82 @@ +// CACHE_INVARIANTS:POSTURE=auth-required +// Cron catch-up endpoint. Shared-secret authenticated; never cached. +// Triggered by an external scheduler (e.g. Cloudflare Cron Trigger via fetch). + +import { NextRequest, NextResponse } from "next/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { createLogger, createConsoleLogger, isLogsRPC } from "@/lib/logging"; +import { runCompetitionCron } from "@/lib/competition/cron"; + +export async function GET() { + return NextResponse.json( + { + endpoint: "/api/competition/cron", + methods: ["POST"], + description: + "Nightly catch-up sweep for the trading-comp verifier. Walks registered_wallets and re-fetches recent Hiro tx history, filtering by allowlist and submitting each match via verifyAndPersistSwap with source='cron'. Defence in depth against chainhook gaps.", + auth: { + scheme: "Shared secret", + header: "X-Cron-Secret: {env.CRON_SECRET}", + }, + response: { + scanned: "number (addresses walked this run)", + found: "number (allowlisted txs touching those addresses)", + inserted: "number (new rows written to swaps)", + alreadyKnown: "number (rows that existed from another ingestion path)", + pending: "number (txs Hiro reported as still in flight)", + rejected: "number (verifier rejected — sender/allowlist/parse failure)", + cursor: "string | null (next stx_address to resume from)", + }, + notes: [ + "Per-run cap: 100 addresses (CRON_MAX_ADDRESSES_PER_RUN). The sweep resumes across runs via the comp:cron:cursor KV key.", + "wrangler cron-trigger wiring is tracked as a follow-up; this route is callable today via HTTPS with the shared secret.", + ], + }, + { headers: { "Cache-Control": "no-store" } } + ); +} + +export async function POST(request: NextRequest) { + const { env, ctx } = await getCloudflareContext(); + const rayId = request.headers.get("cf-ray") || crypto.randomUUID(); + const logger = isLogsRPC(env.LOGS) + ? createLogger(env.LOGS, ctx, { rayId, path: request.nextUrl.pathname }) + : createConsoleLogger({ rayId, path: request.nextUrl.pathname }); + + const expectedSecret = env.CRON_SECRET; + if (!expectedSecret) { + logger.error("CRON_SECRET not configured"); + return NextResponse.json( + { error: "Server configuration error" }, + { status: 500, headers: { "Cache-Control": "no-store" } } + ); + } + + const provided = request.headers.get("x-cron-secret"); + if (!provided || provided !== expectedSecret) { + return NextResponse.json( + { error: "Invalid or missing X-Cron-Secret" }, + { status: 401, headers: { "Cache-Control": "no-store" } } + ); + } + + const db = env.DB as D1Database | undefined; + if (!db) { + logger.warn("D1 binding missing on competition/cron"); + return NextResponse.json( + { + error: "transient_d1_unavailable", + message: "Competition database temporarily unavailable. Please retry shortly.", + retry_after: 60, + }, + { status: 503, headers: { "Retry-After": "60", "Cache-Control": "no-store" } } + ); + } + + const summary = await runCompetitionCron( + { DB: db, VERIFIED_AGENTS: env.VERIFIED_AGENTS, HIRO_API_KEY: env.HIRO_API_KEY }, + logger + ); + + return NextResponse.json(summary, { headers: { "Cache-Control": "no-store" } }); +} From d041df3633c9dc33810e10b72c3bb167779c5e1c Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:01:57 +0545 Subject: [PATCH 24/56] test(competition): unit tests for runCompetitionCron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-D — 9 tests over the cron sweep's walk + dispatch + cursor logic. Hiro history fetcher is injected via the fetchAddressTxsImpl option; verifyAndPersistSwap is module-mocked. Walk + dispatch: - Allowlisted tx → verify called with source='cron', summary increments inserted - Off-allowlist contract → verify NOT called (saves Hiro cost) - Non-contract_call tx → verify NOT called - Mixed result codes are tallied separately (inserted / alreadyKnown / pending / rejected) Cursor persistence: - Full page (== CRON_MAX_ADDRESSES_PER_RUN rows) → cursor saved - Partial page → cursor deleted so the next run starts at the head - WHERE stx_address > ? branch is used when cursor is present - Head-of-list branch is used when cursor is absent Fault tolerance: - A verify throw is counted as rejected; sweep continues to next tx Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/__tests__/cron.test.ts | 250 +++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 lib/competition/__tests__/cron.test.ts diff --git a/lib/competition/__tests__/cron.test.ts b/lib/competition/__tests__/cron.test.ts new file mode 100644 index 00000000..fa7fbcdc --- /dev/null +++ b/lib/competition/__tests__/cron.test.ts @@ -0,0 +1,250 @@ +/** + * Tests for lib/competition/cron.ts + * + * Phase 3.1 PR-D — exercises the catch-up sweep's walk + dispatch + * + cursor-persistence logic. Hiro fetch is injected; verify is mocked. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../verify", () => ({ + verifyAndPersistSwap: vi.fn(), +})); + +import { + runCompetitionCron, + CRON_MAX_ADDRESSES_PER_RUN, +} from "../cron"; +import { verifyAndPersistSwap } from "../verify"; +import type { Mock } from "vitest"; + +const POOL = "SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M"; +const ALLOWED_CONTRACT = `${POOL}.stableswap-stx-ststx-v-1-2`; +const ALLOWED_FN = "swap-x-for-y"; + +function makeKv(initialCursor: string | null = null) { + const store = new Map(); + if (initialCursor) store.set("comp:cron:cursor", initialCursor); + return { + get: vi.fn(async (k: string) => store.get(k) ?? null), + put: vi.fn(async (k: string, v: string) => { + store.set(k, v); + }), + delete: vi.fn(async (k: string) => { + store.delete(k); + }), + _store: store, + } as unknown as KVNamespace & { _store: Map }; +} + +function makeDb(rows: string[]) { + const prepare = vi.fn((sql: string) => { + const usesCursor = sql.includes("stx_address > ?1"); + return { + bind: vi.fn().mockReturnValue({ + all: vi.fn().mockResolvedValue({ + results: rows.map((stx_address) => ({ stx_address })), + }), + }), + _usesCursor: usesCursor, + }; + }); + return { prepare } as unknown as D1Database; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runCompetitionCron — walk + dispatch", () => { + it("walks the address page, finds allowlisted txs, and submits them with source='cron'", async () => { + const kv = makeKv(); + const db = makeDb(["SP_ADDR_001"]); + (verifyAndPersistSwap as Mock).mockResolvedValue({ + status: "verified", + inserted: true, + row: {}, + }); + + const fetchAddressTxsImpl = vi.fn().mockResolvedValue([ + { + tx_id: "0xaaa", + tx_type: "contract_call", + contract_call: { contract_id: ALLOWED_CONTRACT, function_name: ALLOWED_FN }, + }, + ]); + + const summary = await runCompetitionCron( + { DB: db, VERIFIED_AGENTS: kv, HIRO_API_KEY: undefined }, + undefined, + { fetchAddressTxsImpl } + ); + + expect(summary).toMatchObject({ + scanned: 1, + found: 1, + inserted: 1, + alreadyKnown: 0, + rejected: 0, + pending: 0, + }); + expect(verifyAndPersistSwap).toHaveBeenCalledTimes(1); + expect((verifyAndPersistSwap as Mock).mock.calls[0][3]).toBe("cron"); + }); + + it("skips off-allowlist contract calls without invoking verify (saves Hiro cost)", async () => { + const kv = makeKv(); + const db = makeDb(["SP_ADDR_001"]); + const fetchAddressTxsImpl = vi.fn().mockResolvedValue([ + { + tx_id: "0xaaa", + tx_type: "contract_call", + contract_call: { contract_id: "SP00000.unknown-pool", function_name: "swap" }, + }, + ]); + + const summary = await runCompetitionCron( + { DB: db, VERIFIED_AGENTS: kv }, + undefined, + { fetchAddressTxsImpl } + ); + + expect(summary.found).toBe(0); + expect(verifyAndPersistSwap).not.toHaveBeenCalled(); + }); + + it("skips non-contract_call txs without dispatch", async () => { + const kv = makeKv(); + const db = makeDb(["SP_ADDR_001"]); + const fetchAddressTxsImpl = vi.fn().mockResolvedValue([ + { tx_id: "0xaaa", tx_type: "token_transfer" }, + ]); + + const summary = await runCompetitionCron( + { DB: db, VERIFIED_AGENTS: kv }, + undefined, + { fetchAddressTxsImpl } + ); + expect(summary.found).toBe(0); + expect(verifyAndPersistSwap).not.toHaveBeenCalled(); + }); + + it("tallies inserted vs alreadyKnown vs pending vs rejected", async () => { + const kv = makeKv(); + const db = makeDb(["SP_ADDR_001"]); + (verifyAndPersistSwap as Mock) + .mockResolvedValueOnce({ status: "verified", inserted: true, row: {} }) + .mockResolvedValueOnce({ status: "verified", inserted: false, row: {} }) + .mockResolvedValueOnce({ status: "pending" }) + .mockResolvedValueOnce({ status: "rejected", code: "sender_not_registered", reason: "x" }); + + const fetchAddressTxsImpl = vi.fn().mockResolvedValue([ + { tx_id: "0xaaa", tx_type: "contract_call", contract_call: { contract_id: ALLOWED_CONTRACT, function_name: ALLOWED_FN } }, + { tx_id: "0xbbb", tx_type: "contract_call", contract_call: { contract_id: ALLOWED_CONTRACT, function_name: ALLOWED_FN } }, + { tx_id: "0xccc", tx_type: "contract_call", contract_call: { contract_id: ALLOWED_CONTRACT, function_name: ALLOWED_FN } }, + { tx_id: "0xddd", tx_type: "contract_call", contract_call: { contract_id: ALLOWED_CONTRACT, function_name: ALLOWED_FN } }, + ]); + + const summary = await runCompetitionCron( + { DB: db, VERIFIED_AGENTS: kv }, + undefined, + { fetchAddressTxsImpl } + ); + + expect(summary).toMatchObject({ + scanned: 1, + found: 4, + inserted: 1, + alreadyKnown: 1, + pending: 1, + rejected: 1, + }); + }); +}); + +describe("runCompetitionCron — cursor persistence", () => { + it("persists the next cursor when the page is full (more addresses to walk)", async () => { + const kv = makeKv(); + const fullPage: string[] = Array.from( + { length: CRON_MAX_ADDRESSES_PER_RUN }, + (_, i) => `SP_ADDR_${String(i).padStart(3, "0")}` + ); + const db = makeDb(fullPage); + const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); + + const summary = await runCompetitionCron( + { DB: db, VERIFIED_AGENTS: kv }, + undefined, + { fetchAddressTxsImpl } + ); + + expect(summary.cursor).toBe(fullPage[fullPage.length - 1]); + expect(kv.put).toHaveBeenCalledWith("comp:cron:cursor", fullPage[fullPage.length - 1]); + }); + + it("deletes the cursor when the page is partial (walk wrapped)", async () => { + const kv = makeKv("SP_PRIOR_CURSOR"); + const db = makeDb(["SP_ADDR_001", "SP_ADDR_002"]); + const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); + + const summary = await runCompetitionCron( + { DB: db, VERIFIED_AGENTS: kv }, + undefined, + { fetchAddressTxsImpl } + ); + + expect(summary.cursor).toBeNull(); + expect(kv.delete).toHaveBeenCalledWith("comp:cron:cursor"); + }); + + it("uses the cursor query branch when a cursor is present", async () => { + const kv = makeKv("SP_LAST_RUN"); + const db = makeDb([]); + const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); + + await runCompetitionCron( + { DB: db, VERIFIED_AGENTS: kv }, + undefined, + { fetchAddressTxsImpl } + ); + + const prepareCalls = (db.prepare as Mock).mock.calls; + expect(prepareCalls[0][0]).toContain("stx_address > ?1"); + }); + + it("uses the head-of-list query branch when no cursor is present", async () => { + const kv = makeKv(); + const db = makeDb([]); + const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); + + await runCompetitionCron( + { DB: db, VERIFIED_AGENTS: kv }, + undefined, + { fetchAddressTxsImpl } + ); + + const sql = (db.prepare as Mock).mock.calls[0][0] as string; + expect(sql).not.toContain("stx_address > ?"); + }); +}); + +describe("runCompetitionCron — fault tolerance", () => { + it("counts a verify throw as rejected and continues the sweep", async () => { + const kv = makeKv(); + const db = makeDb(["SP_ADDR_001"]); + (verifyAndPersistSwap as Mock).mockRejectedValueOnce(new Error("boom")); + + const fetchAddressTxsImpl = vi.fn().mockResolvedValue([ + { tx_id: "0xaaa", tx_type: "contract_call", contract_call: { contract_id: ALLOWED_CONTRACT, function_name: ALLOWED_FN } }, + ]); + + const summary = await runCompetitionCron( + { DB: db, VERIFIED_AGENTS: kv }, + undefined, + { fetchAddressTxsImpl } + ); + + expect(summary.rejected).toBe(1); + expect(summary.inserted).toBe(0); + }); +}); From e64ad636516bc678ee290f745e17572669b71156 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:02:03 +0545 Subject: [PATCH 25/56] test(competition): route-level tests for POST /api/competition/cron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-D — 8 tests over the cron route's auth gate + dispatch into runCompetitionCron. Auth: - 500 when CRON_SECRET is not configured - 401 when X-Cron-Secret header is absent - 401 when X-Cron-Secret value does not match - 200 when the secret matches Bindings + dispatch: - 503 + Retry-After:60 when D1 binding is missing - 200 with the cron summary on success - Cache-Control: no-store on every shape Self-doc: - GET returns the documentation payload without invoking the cron Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../competition/__tests__/cron-route.test.ts | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 app/api/competition/__tests__/cron-route.test.ts diff --git a/app/api/competition/__tests__/cron-route.test.ts b/app/api/competition/__tests__/cron-route.test.ts new file mode 100644 index 00000000..fd75e0a4 --- /dev/null +++ b/app/api/competition/__tests__/cron-route.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for POST /api/competition/cron — Phase 3.1 PR-D route layer. + * + * Exercises the route's auth gate + dispatch into runCompetitionCron. + * The walk + verifier logic itself is unit-tested in cron.test.ts. + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@opennextjs/cloudflare", () => ({ + getCloudflareContext: vi.fn(), +})); +vi.mock("@/lib/logging", () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + createConsoleLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + isLogsRPC: () => false, +})); +vi.mock("@/lib/competition/cron", () => ({ + runCompetitionCron: vi.fn(), +})); + +import { POST, GET } from "../cron/route"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { runCompetitionCron } from "@/lib/competition/cron"; + +const SECRET = "test-cron-secret"; + +function mockEnv(opts: { omitDb?: boolean; omitSecret?: boolean } = {}) { + const db = opts.omitDb ? undefined : ({ prepare: vi.fn() } as unknown as D1Database); + (getCloudflareContext as Mock).mockReturnValue({ + env: { + DB: db, + VERIFIED_AGENTS: { get: vi.fn(), put: vi.fn(), delete: vi.fn() } as unknown as KVNamespace, + HIRO_API_KEY: undefined, + LOGS: undefined, + ...(opts.omitSecret ? {} : { CRON_SECRET: SECRET }), + }, + ctx: { waitUntil: vi.fn() }, + }); +} + +function buildRequest(secret?: string): NextRequest { + const headers: Record = { "content-type": "application/json" }; + if (secret !== undefined) headers["x-cron-secret"] = secret; + return new NextRequest("https://aibtc.com/api/competition/cron", { + method: "POST", + headers, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/competition/cron — auth", () => { + it("returns 500 when CRON_SECRET is not configured", async () => { + mockEnv({ omitSecret: true }); + const res = await POST(buildRequest(SECRET)); + expect(res.status).toBe(500); + }); + + it("returns 401 when X-Cron-Secret is absent", async () => { + mockEnv(); + const res = await POST(buildRequest(undefined)); + expect(res.status).toBe(401); + }); + + it("returns 401 when X-Cron-Secret does not match", async () => { + mockEnv(); + const res = await POST(buildRequest("wrong-secret")); + expect(res.status).toBe(401); + }); + + it("accepts the request when the secret matches", async () => { + mockEnv(); + (runCompetitionCron as Mock).mockResolvedValue({ + scanned: 0, found: 0, inserted: 0, alreadyKnown: 0, pending: 0, rejected: 0, cursor: null, + }); + const res = await POST(buildRequest(SECRET)); + expect(res.status).toBe(200); + }); +}); + +describe("POST /api/competition/cron — bindings + dispatch", () => { + it("returns 503 + Retry-After when D1 binding is missing", async () => { + mockEnv({ omitDb: true }); + const res = await POST(buildRequest(SECRET)); + expect(res.status).toBe(503); + expect(res.headers.get("Retry-After")).toBe("60"); + }); + + it("returns the cron summary on success", async () => { + mockEnv(); + (runCompetitionCron as Mock).mockResolvedValue({ + scanned: 100, + found: 5, + inserted: 3, + alreadyKnown: 1, + pending: 1, + rejected: 0, + cursor: "SP_NEXT", + }); + const res = await POST(buildRequest(SECRET)); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + scanned: 100, + found: 5, + inserted: 3, + alreadyKnown: 1, + pending: 1, + rejected: 0, + cursor: "SP_NEXT", + }); + }); + + it("sets Cache-Control: no-store on every response shape", async () => { + mockEnv(); + (runCompetitionCron as Mock).mockResolvedValue({ + scanned: 0, found: 0, inserted: 0, alreadyKnown: 0, pending: 0, rejected: 0, cursor: null, + }); + const res = await POST(buildRequest(SECRET)); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + }); +}); + +describe("GET /api/competition/cron — self-doc", () => { + it("returns the documentation payload without invoking the cron", async () => { + mockEnv(); + const res = await GET(); + expect(res.status).toBe(200); + expect(runCompetitionCron).not.toHaveBeenCalled(); + const body = await res.json(); + expect(body.endpoint).toBe("/api/competition/cron"); + }); +}); From f3a14ed010e279848b5153b5f6834096810da445 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:05:07 +0545 Subject: [PATCH 26/56] docs(openapi): publish live POST verifier + chainhook + cron schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-B/C/D — replaces the 501-stub POST entry on /api/competition/trades with the live response matrix (200/202/400/404/422/429/502/503), and adds the two operator-only ingestion routes: - /api/competition/chainhook (POST, HMAC-auth via X-Chainhook-Signature or Authorization: Bearer). Documents both signature header forms and the ingestion-summary response shape. - /api/competition/cron (POST, shared-secret via X-Cron-Secret). Documents the catch-up summary including the resume cursor. OpenAPI now matches what the routes actually do — important for MCP clients (aibtcdev/aibtc-mcp-server#510) that codegen from the spec. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/openapi.json/route.ts | 154 ++++++++++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 8 deletions(-) diff --git a/app/api/openapi.json/route.ts b/app/api/openapi.json/route.ts index f1279597..b2627184 100644 --- a/app/api/openapi.json/route.ts +++ b/app/api/openapi.json/route.ts @@ -1228,11 +1228,14 @@ export function GET() { }, post: { operationId: "submitCompetitionTrade", - summary: "Submit a swap txid for verification (reserved — ships in Phase 3.1 PR-B)", + summary: "Submit a swap txid for verification", description: - "The verifier worker — Hiro fetch, allowlist check, INSERT OR IGNORE — ships in " + - "Phase 3.1 PR-B (issue #734). The method is reserved on this route so callers can " + - "discover the contract early; until PR-B lands this endpoint returns 501.", + "Agent-submit fast path. The server fetches the tx from Hiro, runs sender + " + + "allowlist checks, parses the FT/STX transfer events, and persists via " + + "INSERT OR IGNORE on (txid). First writer wins across the three ingestion paths " + + "(agent / cron / chainhook). Pending txs return 202 and are tracked in KV " + + "(comp:pending:{txid}, 30-min TTL) — no row is written for pending. Rate limit: " + + "20/min per IP (RATE_LIMIT_MUTATING).", requestBody: { required: true, content: { @@ -1241,21 +1244,156 @@ export function GET() { type: "object", required: ["txid"], properties: { - txid: { type: "string", description: "Stacks tx hash (0x-prefixed)" }, + txid: { + type: "string", + description: "Stacks tx hash, 64 hex chars (0x-prefix accepted).", + pattern: "^(0x)?[0-9a-fA-F]{64}$", + }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "Verified — body is the persisted SwapRow", + content: { "application/json": { schema: { type: "object" } } }, + }, + "202": { + description: + "Pending — tx not yet confirmed. Body is `{ accepted: true }`. Re-poll later; the pending state is tracked in KV (30-min TTL) so repeats short-circuit without hitting Hiro.", + content: { "application/json": { schema: { type: "object" } } }, + }, + "400": { + description: "Malformed body or txid", + content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }, + }, + "404": { + description: "Hiro could not find the txid", + content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }, + }, + "422": { + description: + "Sender not in registered_wallets, contract+function off allowlist, or terminal failure status / parse failure. Body includes `{ error, code, retryable: false }`.", + content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }, + }, + "429": { + description: "Rate limited (per-IP mutating bucket)", + content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }, + }, + "502": { + description: "Hiro upstream error — retryable", + content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }, + }, + "503": { + description: "D1 temporarily unavailable — retry per `Retry-After` header", + content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }, + }, + }, + }, + }, + "/api/competition/chainhook": { + post: { + operationId: "submitCompetitionChainhook", + summary: "Receive a chainhook predicate firing (HMAC-authenticated)", + description: + "Receives Hiro chainhook predicate firings for the trading competition. The handler " + + "iterates `apply` and submits each tx to the verifier with `source='chainhook'`. " + + "Rollback entries are ignored — see lib/competition/chainhook.ts for the rationale.", + parameters: [ + { + name: "X-Chainhook-Signature", + in: "header", + required: false, + description: + "Hex HMAC-SHA256(env.CHAINHOOK_SECRET, request_body). Either this header or `Authorization: Bearer {hex}` must be present.", + schema: { type: "string" }, + }, + { + name: "Authorization", + in: "header", + required: false, + description: "`Bearer {hex}` form of the HMAC signature (Hiro controller default).", + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + description: + "Standard chainhook envelope with `apply` (and optional `rollback`) arrays of tx entries.", + }, + }, + }, + }, + responses: { + "200": { + description: "Batch processed — body is the ingestion summary", + content: { + "application/json": { + schema: { + type: "object", + properties: { + processed: { type: "integer" }, + inserted: { type: "integer" }, + alreadyKnown: { type: "integer" }, + rejected: { type: "integer" }, + pending: { type: "integer" }, + }, }, }, }, }, + "400": { description: "Malformed JSON or missing `apply` field" }, + "401": { description: "Missing or invalid signature" }, + "500": { description: "Server config error (CHAINHOOK_SECRET not set)" }, + "503": { description: "D1 temporarily unavailable" }, }, + }, + }, + "/api/competition/cron": { + post: { + operationId: "runCompetitionCron", + summary: "Run the nightly catch-up sweep (shared-secret authenticated)", + description: + "Walks registered_wallets, fetches recent Hiro tx history per address, filters by allowlist, " + + "and submits each match via the verifier with `source='cron'`. Per-run cap: 100 addresses; " + + "resumes across runs via the `comp:cron:cursor` KV key.", + parameters: [ + { + name: "X-Cron-Secret", + in: "header", + required: true, + description: "Shared secret matching env.CRON_SECRET.", + schema: { type: "string" }, + }, + ], responses: { - "501": { - description: "Not yet implemented — ships in Phase 3.1 PR-B", + "200": { + description: "Sweep complete — body is the run summary", content: { "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, + schema: { + type: "object", + properties: { + scanned: { type: "integer" }, + found: { type: "integer" }, + inserted: { type: "integer" }, + alreadyKnown: { type: "integer" }, + pending: { type: "integer" }, + rejected: { type: "integer" }, + cursor: { type: ["string", "null"] }, + }, + }, }, }, }, + "401": { description: "Missing or invalid X-Cron-Secret" }, + "500": { description: "Server config error (CRON_SECRET not set)" }, + "503": { description: "D1 temporarily unavailable" }, }, }, }, From 9b93bea250b4b2ba695914a9280f5b655f2aeea1 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:05:12 +0545 Subject: [PATCH 27/56] docs(llms): list live verifier + chainhook + cron routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-B/C/D — drops the 'verifier ships in PR-B' caveat now that POST is live, and lists the two operator-only ingestion routes so agents reading llms.txt see the full trading-comp surface. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/llms.txt/route.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index d33450b8..e177c6e6 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -119,11 +119,13 @@ All endpoints return self-documenting JSON on GET. - POST /api/outbox/{address} — reply (free, signature) - GET /api/outbox/{address} — list outbox (free) -### Trading Competition (Free reads; POST verifier ships in Phase 3.1 PR-B) +### Trading Competition - GET /api/competition/status?address={stx} — membership + verified trade counts (unregistered → \`registered: false\`, not 404) - GET /api/competition/trades?address={stx}&limit=50&cursor=… — paginated swap history (keyset over burn_block_time, txid) -- POST /api/competition/trades — submit a txid for verification (currently 501; verifier lands in PR-B) +- POST /api/competition/trades — submit a txid for verification (Hiro fetch + allowlist + INSERT OR IGNORE; 202 if pending, 200 if verified, 422 if rejected) +- POST /api/competition/chainhook — chainhook ingestion (HMAC-authenticated, operator-only) +- POST /api/competition/cron — nightly catch-up sweep (shared-secret, operator-only) ### Progression (Free) From b3bc8506705f85f3d826a8acd1204a3bcc38a9ae Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:05:20 +0545 Subject: [PATCH 28/56] docs(llms-full): live POST verifier section + chainhook + cron blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-B/C/D — replaces the 'Submit Trade (501 until PR-B)' section with the live response matrix, and adds two new operator-only sections: - Chainhook Ingestion: HMAC auth, rollback-ignored rationale, ingestion-summary response. - Cron Catch-Up: shared-secret auth, KV-cursor mechanic, per-run cap, defence-in-depth framing. llms-full.txt is the doc agents read for the curl examples + full matrices; keeping it in sync with the actual route behaviour saves debugging cycles downstream. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/llms-full.txt/route.ts | 40 ++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/app/llms-full.txt/route.ts b/app/llms-full.txt/route.ts index 55b25023..85d40342 100644 --- a/app/llms-full.txt/route.ts +++ b/app/llms-full.txt/route.ts @@ -939,7 +939,7 @@ next page, pass that value back as \`?cursor=…\` — pagination is keyset over \`limit\` is 1–200 (default 50). \`scored_value\` / \`scored_at\` are populated by Phase 3.2 scoring (separate sub-issue) and remain \`null\` for unscored rows. -### Submit Trade (501 until PR-B) +### Submit Trade \`\`\`bash curl -X POST https://aibtc.com/api/competition/trades \\ @@ -947,16 +947,40 @@ curl -X POST https://aibtc.com/api/competition/trades \\ -d '{"txid":"0x46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4"}' \`\`\` -Currently returns \`501 not_implemented\`. The verifier worker — Hiro fetch, -allowlist check, FT/STX event parsing, INSERT OR IGNORE — ships in Phase 3.1 -PR-B. The route is reserved so callers can discover the contract early. When -PR-B lands, behaviour will be: +Response matrix: -- \`202 { accepted: true }\` — tx is pending; re-poll. Pending state is tracked in - KV (\`comp:pending:{txid}\`, 30-min TTL), NOT in D1. - \`200\` — newly verified or idempotent re-submission (first writer wins on \`(txid)\`). + Body is the persisted SwapRow. +- \`202 { accepted: true }\` — tx is pending; re-poll. Pending state is tracked in + KV (\`comp:pending:{txid}\`, 30-min TTL), NOT in D1. Repeat polls short-circuit + without re-hitting Hiro until the TTL expires or the tx settles. - \`422\` — sender not in registered_wallets, or contract+function not on the - allowlist, or tx failed terminally. + allowlist, or tx failed terminally / parse failed. Body: \`{ error, code, retryable: false }\`. +- \`404\` — Hiro could not find the txid. +- \`429\` — rate limited (20/min per IP). Retry-After header set. +- \`502\` — Hiro upstream error; retryable. +- \`503\` — D1 temporarily unavailable; retry per Retry-After header. + +### Chainhook Ingestion (operator-only) + +\`POST /api/competition/chainhook\` receives Hiro chainhook predicate firings. +HMAC-authenticated via \`CHAINHOOK_SECRET\` (header \`X-Chainhook-Signature\` or +\`Authorization: Bearer {hex}\`). Iterates \`apply\` and submits each tx with +\`source='chainhook'\`. Rollback entries are ignored — the verifier persists +only terminal-status rows; a rolled-back tx simply never replays, and the +historical row stays in \`swaps\` as audit. + +Response is the ingestion summary: \`{ processed, inserted, alreadyKnown, rejected, pending }\`. + +### Cron Catch-Up (operator-only) + +\`POST /api/competition/cron\` runs the nightly catch-up sweep. Walks +\`registered_wallets\` (100 addresses per run, resumes via \`comp:cron:cursor\` KV +key), fetches each address's recent Hiro tx history, filters by allowlist, and +submits matches with \`source='cron'\`. Shared-secret authenticated via +\`X-Cron-Secret\`. Defence in depth against chainhook gaps. + +Response: \`{ scanned, found, inserted, alreadyKnown, pending, rejected, cursor }\`. ### Schema Notes From 07c7d2181c0473be48a6b314edc670753e70bd12 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:05:27 +0545 Subject: [PATCH 29/56] docs(agent-card): expand trading-comp skill with verifier description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-B/C/D — updates the trading-comp skill block to call out the live POST verifier behaviour (Hiro fetch → allowlist + sender → INSERT OR IGNORE), the 30-min KV pending TTL, and the three-source convergence model. Adds the 'submit a swap txid' example. The skill description is what A2A-discovery callers (and the MCP server at aibtcdev/aibtc-mcp-server#510) read to decide what tools to expose to their agents; keeping it precise saves a layer of indirection. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/.well-known/agent.json/route.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/.well-known/agent.json/route.ts b/app/.well-known/agent.json/route.ts index 69d62d20..dbabe673 100644 --- a/app/.well-known/agent.json/route.ts +++ b/app/.well-known/agent.json/route.ts @@ -370,16 +370,20 @@ export function GET() { id: "trading-comp", name: "Trading Competition", description: - "Trading-comp surface for the AIBTC verifier. GET /api/competition/status?address={stx} " + - "returns membership + verified trade counts; unregistered addresses come back as " + - "{ registered: false } (not 404) so callers route to identity_register. " + + "Trading-comp surface for the AIBTC verifier. " + + "GET /api/competition/status?address={stx} returns membership + verified trade counts; " + + "unregistered addresses come back as { registered: false } (not 404) so callers route to identity_register. " + "GET /api/competition/trades?address={stx}&limit=50&cursor=… returns paginated swap " + - "history with keyset pagination over (burn_block_time, txid). POST /api/competition/trades " + - "is reserved for the verifier worker (ships in Phase 3.1 PR-B; currently 501).", + "history with keyset pagination over (burn_block_time, txid). " + + "POST /api/competition/trades submits a Stacks txid for verification — server fetches via Hiro, " + + "runs allowlist + sender checks, INSERT OR IGNOREs into the swaps table (first writer wins). " + + "Pending txs return 202 and are tracked in KV with a 30-min TTL (no D1 row for pending). " + + "Three ingestion paths converge on the same row: agent-submit (this POST), chainhook, and nightly cron.", tags: ["competition", "trading", "swaps", "leaderboard"], examples: [ "Get my trading-comp status", "List my recent swaps", + "Submit a swap txid for verification", "Check if my STX address is registered for the competition", ], inputModes: ["application/json"], From fbe238d675f0981d7620c13430eac12a04ba2c1e Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:16:42 +0545 Subject: [PATCH 30/56] revert(competition): remove chainhook receiver route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-C scope-cut. The receiver was built per the original issue #734 §3.1.c, but per design discussion the real-time stream isn't worth the cost right now: - Agent-submit (POST /api/competition/trades) catches every swap the agent does — the agent already knows their own txid because they just executed the swap. - Cron at 15-min cadence (retuned in the next commit, was nightly) catches everything agent-submit misses, with the same Hiro client + rate-limit budget + code path. - Chainhook overhead: separate Hiro hosted-predicate signup OR self-hosted Stacks node + chainhook service, plus predicate re-registration whenever the allowlist changes. What we keep: - The 'chainhook' value in the swaps.source enum (migration 005, already merged). Schema doesn't move; the value is available the moment we revisit this. - The verifier (lib/competition/verify.ts) takes source as a parameter — nothing to change there. When chainhook becomes worth it: live trade ticker UI, sub-minute leaderboard refresh, or detecting unregistered addresses trading the campaign contracts so we can prompt them to register. None of those are current product surfaces. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/competition/chainhook/route.ts | 160 ------------------------- 1 file changed, 160 deletions(-) delete mode 100644 app/api/competition/chainhook/route.ts diff --git a/app/api/competition/chainhook/route.ts b/app/api/competition/chainhook/route.ts deleted file mode 100644 index 295f7a25..00000000 --- a/app/api/competition/chainhook/route.ts +++ /dev/null @@ -1,160 +0,0 @@ -// CACHE_INVARIANTS:POSTURE=auth-required -// This route accepts chainhook predicate firings from Hiro's controller -// (or a self-hosted controller). HMAC-authenticated; no public cache. - -import { NextRequest, NextResponse } from "next/server"; -import { getCloudflareContext } from "@opennextjs/cloudflare"; -import { createLogger, createConsoleLogger, isLogsRPC } from "@/lib/logging"; -import { - parseChainhookPayload, - extractChainhookSignature, - verifyChainhookSignature, -} from "@/lib/competition/chainhook"; -import { verifyAndPersistSwap } from "@/lib/competition/verify"; - -interface IngestSummary { - scanned: number; - inserted: number; - alreadyKnown: number; - rejected: number; - pending: number; -} - -export async function GET() { - return NextResponse.json( - { - endpoint: "/api/competition/chainhook", - method: "POST", - description: - "Receives Hiro chainhook predicate firings for the trading competition. HMAC-authenticated via CHAINHOOK_SECRET. Each tx in `apply` is handed to verifyAndPersistSwap with source='chainhook'. Rollback entries are ignored (the verifier persists only terminal-status rows; rolled-back txs simply never replay).", - auth: { - scheme: "HMAC-SHA256", - header: "Authorization: Bearer {hex} (or X-Chainhook-Signature: {hex})", - body: "HMAC-SHA256(env.CHAINHOOK_SECRET, request_body)", - }, - response: { - "200": { processed: "number", inserted: "number", rejected: "number" }, - "401": "Missing or invalid signature", - "400": "Malformed JSON payload", - "503": "D1 unavailable — retry", - }, - notes: [ - "Predicate registration is OUT OF SCOPE of this route; configure the chainhook controller against the contracts in lib/competition/allowlist.ts.", - "Source enum: this route always writes source='chainhook'. First-writer-wins on (txid).", - ], - }, - { headers: { "Cache-Control": "no-store" } } - ); -} - -export async function POST(request: NextRequest) { - const { env, ctx } = await getCloudflareContext(); - const rayId = request.headers.get("cf-ray") || crypto.randomUUID(); - const logger = isLogsRPC(env.LOGS) - ? createLogger(env.LOGS, ctx, { rayId, path: request.nextUrl.pathname }) - : createConsoleLogger({ rayId, path: request.nextUrl.pathname }); - - const secret = env.CHAINHOOK_SECRET; - if (!secret) { - logger.error("CHAINHOOK_SECRET not configured"); - return NextResponse.json( - { error: "Server configuration error" }, - { status: 500, headers: { "Cache-Control": "no-store" } } - ); - } - - const providedSig = extractChainhookSignature(request.headers); - if (!providedSig) { - return NextResponse.json( - { error: "Missing chainhook signature (Authorization: Bearer … or X-Chainhook-Signature)" }, - { status: 401, headers: { "Cache-Control": "no-store" } } - ); - } - - // Read raw body once — both signature verify and JSON parse need it. - const rawBody = await request.text(); - - const sigValid = await verifyChainhookSignature(rawBody, providedSig, secret); - if (!sigValid) { - logger.warn("Chainhook signature mismatch"); - return NextResponse.json( - { error: "Invalid chainhook signature" }, - { status: 401, headers: { "Cache-Control": "no-store" } } - ); - } - - let payload: unknown; - try { - payload = JSON.parse(rawBody); - } catch { - return NextResponse.json( - { error: "Body is not valid JSON" }, - { status: 400, headers: { "Cache-Control": "no-store" } } - ); - } - - const parseRes = parseChainhookPayload(payload); - if (!parseRes.ok) { - return NextResponse.json( - { error: parseRes.reason }, - { status: 400, headers: { "Cache-Control": "no-store" } } - ); - } - - const db = env.DB as D1Database | undefined; - if (!db) { - logger.warn("D1 binding missing on competition/chainhook POST"); - return NextResponse.json( - { - error: "transient_d1_unavailable", - message: "Competition database temporarily unavailable. Please retry shortly.", - retry_after: 5, - }, - { status: 503, headers: { "Retry-After": "5", "Cache-Control": "no-store" } } - ); - } - - const summary: IngestSummary = { - scanned: parseRes.txids.length, - inserted: 0, - alreadyKnown: 0, - rejected: 0, - pending: 0, - }; - - // Process txids serially. Chainhook batches are small (predicate firings - // per block) and serial processing keeps Hiro rate-limit exposure simple; - // verifyAndPersistSwap already handles its own retries. - for (const txid of parseRes.txids) { - try { - const result = await verifyAndPersistSwap(env, db, txid, "chainhook", logger); - if (result.status === "verified") { - if (result.inserted) summary.inserted++; - else summary.alreadyKnown++; - } else if (result.status === "pending") { - summary.pending++; - } else { - summary.rejected++; - logger.info("Chainhook txid rejected", { - txid, - code: result.code, - reason: result.reason, - }); - } - } catch (err) { - summary.rejected++; - logger.warn("Chainhook verify threw", { txid, error: String(err) }); - } - } - - return NextResponse.json( - { - processed: summary.scanned, - inserted: summary.inserted, - alreadyKnown: summary.alreadyKnown, - rejected: summary.rejected, - pending: summary.pending, - }, - { status: 200, headers: { "Cache-Control": "no-store" } } - ); -} From 54d290094b9bc656eb59f48ddf59cac2707a4fbb Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:16:52 +0545 Subject: [PATCH 31/56] revert(competition): remove chainhook payload + HMAC helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-C scope-cut. Helpers were the building blocks for the receiver route (just removed in the previous commit) — no other caller, no value alone. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/chainhook.ts | 158 ----------------------------------- 1 file changed, 158 deletions(-) delete mode 100644 lib/competition/chainhook.ts diff --git a/lib/competition/chainhook.ts b/lib/competition/chainhook.ts deleted file mode 100644 index 1b6d7672..00000000 --- a/lib/competition/chainhook.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Chainhook payload validation + HMAC auth for the trading-comp verifier. - * - * Phase 3.1 PR-C — receives chainhook predicate firings from Hiro's - * controller (or our own self-hosted controller). The chainhook payload - * is a JSON envelope with `apply` (rolled-forward txs) and `rollback` - * (rolled-back txs) arrays; for the trading-comp surface we only care - * about `apply`. Each apply entry contains a transaction the predicate - * matched against — we hand each one to verifyAndPersistSwap with - * source='chainhook'. - * - * Auth: HMAC over the request body using `env.CHAINHOOK_SECRET`. The - * controller computes HMAC-SHA256(body) and sends it as either: - * - `Authorization: Bearer {hex}` (hiro chainhook controller format) - * - `X-Chainhook-Signature: {hex}` (our convenience header) - * - * Predicate registration is OUT OF SCOPE of this PR. The chainhook needs - * to be registered against the contracts in lib/competition/allowlist.ts — - * that's a follow-up because it requires controller config rather than - * landing-page changes. - * - * See: app/api/competition/chainhook/route.ts (transport layer). - */ - -export interface ChainhookApplyEntry { - /** The transaction body — we read tx_id from it and re-fetch via Hiro. */ - transaction?: { - transaction_identifier?: { hash?: string }; - }; - /** Some chainhook payloads include the tx hash at the entry level. */ - txid?: string; -} - -export interface ChainhookPayload { - apply?: ChainhookApplyEntry[] | unknown; - rollback?: unknown; -} - -/** Result of payload validation. */ -export type ParseChainhookResult = - | { ok: true; txids: string[] } - | { ok: false; reason: string }; - -const TXID_RE = /^(0x)?[0-9a-fA-F]{64}$/; - -function normalizeTxid(raw: unknown): string | null { - if (typeof raw !== "string") return null; - const trimmed = raw.trim(); - if (!TXID_RE.test(trimmed)) return null; - return trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`; -} - -/** - * Pull the txid list out of a chainhook payload. - * - * Tolerant of both payload shapes (hash at entry-level OR nested inside - * transaction.transaction_identifier) so we don't break if Hiro's - * envelope shifts between controller versions. - * - * Rollback entries are deliberately ignored: a rollback means "this tx - * is no longer canonical", not "this tx happened". The verifier - * persists only terminal-status rows; if a row is rolled back, a future - * apply with the new canonical txid will write a fresh row (different - * txid → different PK). The original row stays in `swaps` as historical - * audit (scoring queries can filter on tx_status if needed). - */ -export function parseChainhookPayload(payload: unknown): ParseChainhookResult { - if (typeof payload !== "object" || payload === null) { - return { ok: false, reason: "Payload is not a JSON object" }; - } - const obj = payload as ChainhookPayload; - const apply = obj.apply; - if (apply === undefined) { - return { ok: false, reason: "Payload missing required `apply` field" }; - } - if (!Array.isArray(apply)) { - return { ok: false, reason: "`apply` must be an array" }; - } - - const txids: string[] = []; - for (const entry of apply as ChainhookApplyEntry[]) { - if (typeof entry !== "object" || entry === null) continue; - const fromTop = normalizeTxid(entry.txid); - if (fromTop) { - txids.push(fromTop); - continue; - } - const fromNested = normalizeTxid(entry.transaction?.transaction_identifier?.hash); - if (fromNested) { - txids.push(fromNested); - } - } - // Dedupe — chainhook controllers occasionally batch the same txid twice - // when the predicate matches multiple events on one tx. - return { ok: true, txids: Array.from(new Set(txids)) }; -} - -/** Compute HMAC-SHA256(secret, body) and return the lowercase hex digest. */ -export async function computeChainhookSignature( - body: string, - secret: string -): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body)); - return [...new Uint8Array(sig)] - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -/** - * Extract the signature from either `Authorization: Bearer …` or - * `X-Chainhook-Signature: …`. Returns lowercase hex or null. - */ -export function extractChainhookSignature(headers: Headers): string | null { - const explicit = headers.get("x-chainhook-signature"); - if (explicit) return explicit.trim().toLowerCase(); - const auth = headers.get("authorization"); - if (auth?.toLowerCase().startsWith("bearer ")) { - return auth.slice(7).trim().toLowerCase(); - } - return null; -} - -/** - * Constant-time HMAC compare. Both sides are hashed under a fixed key - * before the equality check so the comparison runs in deterministic time - * regardless of input mismatch. - */ -export async function verifyChainhookSignature( - body: string, - providedSig: string, - secret: string -): Promise { - const expected = await computeChainhookSignature(body, secret); - // Compare via second HMAC layer (same trick as lib/admin/auth.ts) - const encoder = new TextEncoder(); - const compareKey = await crypto.subtle.importKey( - "raw", - encoder.encode("chainhook-compare"), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - const [a, b] = await Promise.all([ - crypto.subtle.sign("HMAC", compareKey, encoder.encode(expected)), - crypto.subtle.sign("HMAC", compareKey, encoder.encode(providedSig)), - ]); - const hexA = [...new Uint8Array(a)].map((x) => x.toString(16).padStart(2, "0")).join(""); - const hexB = [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join(""); - return hexA === hexB; -} From 4d2cd41533fe47703aa26cc078f5ed73c1794ea8 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:17:04 +0545 Subject: [PATCH 32/56] revert(competition): remove chainhook tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-C scope-cut. Tests for code that no longer exists. (Combining the two test files into one commit because they have no caller-callee relationship — they were sibling test files for sibling code files, and per-file commits for deletions of dead tests adds noise without review value.) Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/chainhook-route.test.ts | 195 ------------------ lib/competition/__tests__/chainhook.test.ts | 151 -------------- 2 files changed, 346 deletions(-) delete mode 100644 app/api/competition/__tests__/chainhook-route.test.ts delete mode 100644 lib/competition/__tests__/chainhook.test.ts diff --git a/app/api/competition/__tests__/chainhook-route.test.ts b/app/api/competition/__tests__/chainhook-route.test.ts deleted file mode 100644 index c7907e6d..00000000 --- a/app/api/competition/__tests__/chainhook-route.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Tests for POST /api/competition/chainhook — Phase 3.1 PR-C. - * - * Exercises the route's auth + dispatch responsibilities. The - * verifier's own logic is unit-tested in lib/competition/__tests__. - */ - -import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; -import { NextRequest } from "next/server"; - -vi.mock("@opennextjs/cloudflare", () => ({ - getCloudflareContext: vi.fn(), -})); -vi.mock("@/lib/logging", () => ({ - createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), - createConsoleLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), - isLogsRPC: () => false, -})); -vi.mock("@/lib/competition/verify", () => ({ - verifyAndPersistSwap: vi.fn(), -})); - -import { POST } from "../chainhook/route"; -import { getCloudflareContext } from "@opennextjs/cloudflare"; -import { verifyAndPersistSwap } from "@/lib/competition/verify"; -import { computeChainhookSignature } from "@/lib/competition/chainhook"; - -const SECRET = "test-chainhook-secret"; -const TXID = "0x46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4"; - -interface MockEnvOpts { - omitDb?: boolean; - omitSecret?: boolean; -} - -function mockEnv(opts: MockEnvOpts = {}) { - const db = opts.omitDb ? undefined : ({ prepare: vi.fn() } as unknown as D1Database); - (getCloudflareContext as Mock).mockReturnValue({ - env: { - DB: db, - LOGS: undefined, - ...(opts.omitSecret ? {} : { CHAINHOOK_SECRET: SECRET }), - }, - ctx: { waitUntil: vi.fn() }, - }); -} - -async function buildSignedRequest(body: unknown): Promise { - const bodyText = typeof body === "string" ? body : JSON.stringify(body); - const sig = await computeChainhookSignature(bodyText, SECRET); - return new NextRequest("https://aibtc.com/api/competition/chainhook", { - method: "POST", - headers: { - "content-type": "application/json", - "x-chainhook-signature": sig, - }, - body: bodyText, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("POST /api/competition/chainhook — auth gates", () => { - it("returns 500 when CHAINHOOK_SECRET is not configured", async () => { - mockEnv({ omitSecret: true }); - const req = new NextRequest("https://aibtc.com/api/competition/chainhook", { - method: "POST", - headers: { "content-type": "application/json", "x-chainhook-signature": "abc" }, - body: "{}", - }); - const res = await POST(req); - expect(res.status).toBe(500); - }); - - it("returns 401 when no signature header is present", async () => { - mockEnv(); - const req = new NextRequest("https://aibtc.com/api/competition/chainhook", { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{}", - }); - const res = await POST(req); - expect(res.status).toBe(401); - }); - - it("returns 401 when the signature is wrong", async () => { - mockEnv(); - const req = new NextRequest("https://aibtc.com/api/competition/chainhook", { - method: "POST", - headers: { - "content-type": "application/json", - "x-chainhook-signature": "0".repeat(64), - }, - body: JSON.stringify({ apply: [] }), - }); - const res = await POST(req); - expect(res.status).toBe(401); - }); - - it("accepts Authorization: Bearer …", async () => { - mockEnv(); - const body = JSON.stringify({ apply: [] }); - const sig = await computeChainhookSignature(body, SECRET); - const req = new NextRequest("https://aibtc.com/api/competition/chainhook", { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${sig}`, - }, - body, - }); - const res = await POST(req); - expect(res.status).toBe(200); - }); -}); - -describe("POST /api/competition/chainhook — payload + dispatch", () => { - it("returns 400 on invalid JSON", async () => { - mockEnv(); - const body = "not-json"; - const sig = await computeChainhookSignature(body, SECRET); - const req = new NextRequest("https://aibtc.com/api/competition/chainhook", { - method: "POST", - headers: { "content-type": "application/json", "x-chainhook-signature": sig }, - body, - }); - const res = await POST(req); - expect(res.status).toBe(400); - }); - - it("returns 400 when apply is missing", async () => { - mockEnv(); - const res = await POST(await buildSignedRequest({ rollback: [] })); - expect(res.status).toBe(400); - }); - - it("returns 503 when DB binding is missing", async () => { - mockEnv({ omitDb: true }); - const res = await POST(await buildSignedRequest({ apply: [{ txid: TXID }] })); - expect(res.status).toBe(503); - expect(res.headers.get("Retry-After")).toBe("5"); - }); - - it("dispatches each txid to verifyAndPersistSwap with source='chainhook'", async () => { - mockEnv(); - (verifyAndPersistSwap as Mock).mockResolvedValue({ - status: "verified", - inserted: true, - row: { txid: TXID } as unknown, - }); - const res = await POST(await buildSignedRequest({ apply: [{ txid: TXID }] })); - expect(res.status).toBe(200); - expect(verifyAndPersistSwap).toHaveBeenCalledTimes(1); - expect((verifyAndPersistSwap as Mock).mock.calls[0][3]).toBe("chainhook"); - const body = await res.json(); - expect(body.processed).toBe(1); - expect(body.inserted).toBe(1); - }); - - it("counts already-known vs newly-inserted vs pending vs rejected", async () => { - mockEnv(); - (verifyAndPersistSwap as Mock) - .mockResolvedValueOnce({ status: "verified", inserted: true, row: {} }) - .mockResolvedValueOnce({ status: "verified", inserted: false, row: {} }) - .mockResolvedValueOnce({ status: "pending" }) - .mockResolvedValueOnce({ status: "rejected", code: "sender_not_registered", reason: "x" }); - const apply = [ - { txid: "0x" + "a".repeat(64) }, - { txid: "0x" + "b".repeat(64) }, - { txid: "0x" + "c".repeat(64) }, - { txid: "0x" + "d".repeat(64) }, - ]; - const res = await POST(await buildSignedRequest({ apply })); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body).toMatchObject({ - processed: 4, - inserted: 1, - alreadyKnown: 1, - pending: 1, - rejected: 1, - }); - }); - - it("returns 200 with processed:0 on an empty apply batch (no spurious 4xx)", async () => { - mockEnv(); - const res = await POST(await buildSignedRequest({ apply: [] })); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.processed).toBe(0); - expect(verifyAndPersistSwap).not.toHaveBeenCalled(); - }); -}); diff --git a/lib/competition/__tests__/chainhook.test.ts b/lib/competition/__tests__/chainhook.test.ts deleted file mode 100644 index ae749b43..00000000 --- a/lib/competition/__tests__/chainhook.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Tests for lib/competition/chainhook.ts - * - * Phase 3.1 PR-C — payload parsing + HMAC signature verification. - */ - -import { describe, it, expect } from "vitest"; -import { - parseChainhookPayload, - computeChainhookSignature, - extractChainhookSignature, - verifyChainhookSignature, -} from "../chainhook"; - -const TXID = "0x46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4"; -const TXID_BARE = "46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4"; - -describe("parseChainhookPayload", () => { - it("rejects null / non-object payload", () => { - expect(parseChainhookPayload(null).ok).toBe(false); - expect(parseChainhookPayload(42).ok).toBe(false); - expect(parseChainhookPayload("foo").ok).toBe(false); - }); - - it("rejects payload missing `apply`", () => { - const r = parseChainhookPayload({ rollback: [] }); - expect(r.ok).toBe(false); - if (r.ok) return; - expect(r.reason).toMatch(/apply/); - }); - - it("rejects `apply` that is not an array", () => { - const r = parseChainhookPayload({ apply: "not-array" }); - expect(r.ok).toBe(false); - }); - - it("extracts txids from entry-level `txid`", () => { - const r = parseChainhookPayload({ apply: [{ txid: TXID }] }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.txids).toEqual([TXID]); - }); - - it("extracts txids from nested transaction_identifier.hash (Hiro shape)", () => { - const r = parseChainhookPayload({ - apply: [ - { transaction: { transaction_identifier: { hash: TXID } } }, - ], - }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.txids).toEqual([TXID]); - }); - - it("normalizes bare-hex txids to 0x-prefixed", () => { - const r = parseChainhookPayload({ apply: [{ txid: TXID_BARE }] }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.txids).toEqual([TXID]); - }); - - it("dedupes repeated txids in a batch", () => { - const r = parseChainhookPayload({ - apply: [ - { txid: TXID }, - { txid: TXID }, - { transaction: { transaction_identifier: { hash: TXID } } }, - ], - }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.txids).toEqual([TXID]); - }); - - it("ignores malformed entries (no hash anywhere) without rejecting the whole batch", () => { - const r = parseChainhookPayload({ - apply: [ - { txid: TXID }, - { foo: "bar" }, - { transaction: {} }, - ], - }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.txids).toEqual([TXID]); - }); - - it("returns empty txid list for empty apply array (200, no inserts downstream)", () => { - const r = parseChainhookPayload({ apply: [] }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.txids).toEqual([]); - }); -}); - -describe("extractChainhookSignature", () => { - it("prefers X-Chainhook-Signature when both headers are present", () => { - const h = new Headers({ - "x-chainhook-signature": "ABC123", - authorization: "Bearer DEF456", - }); - expect(extractChainhookSignature(h)).toBe("abc123"); - }); - - it("falls back to Authorization: Bearer …", () => { - const h = new Headers({ authorization: "Bearer FACE" }); - expect(extractChainhookSignature(h)).toBe("face"); - }); - - it("returns null when no signature header is present", () => { - const h = new Headers({ "content-type": "application/json" }); - expect(extractChainhookSignature(h)).toBeNull(); - }); - - it("returns null on non-Bearer Authorization scheme", () => { - const h = new Headers({ authorization: "Basic abc123" }); - expect(extractChainhookSignature(h)).toBeNull(); - }); -}); - -describe("verifyChainhookSignature", () => { - it("returns true when the signature matches the body+secret HMAC", async () => { - const body = JSON.stringify({ apply: [] }); - const secret = "test-secret-key"; - const sig = await computeChainhookSignature(body, secret); - expect(await verifyChainhookSignature(body, sig, secret)).toBe(true); - }); - - it("returns false when the signature is wrong", async () => { - const body = JSON.stringify({ apply: [] }); - const secret = "test-secret-key"; - const sig = await computeChainhookSignature("DIFFERENT BODY", secret); - expect(await verifyChainhookSignature(body, sig, secret)).toBe(false); - }); - - it("returns false when the secret is wrong (attacker has body but not secret)", async () => { - const body = JSON.stringify({ apply: [] }); - const sig = await computeChainhookSignature(body, "right-secret"); - expect(await verifyChainhookSignature(body, sig, "wrong-secret")).toBe(false); - }); - - it("is case-insensitive on the signature hex (lowercases on input via extract)", async () => { - // verifyChainhookSignature itself does case-sensitive compare on the hex; - // upstream extraction lowercases. We assert the contract by lowercasing here. - const body = JSON.stringify({ apply: [] }); - const secret = "test-secret-key"; - const sig = (await computeChainhookSignature(body, secret)).toUpperCase(); - expect(await verifyChainhookSignature(body, sig, secret)).toBe(false); - expect(await verifyChainhookSignature(body, sig.toLowerCase(), secret)).toBe(true); - }); -}); From f46311f64392777e6af3dbaf9199456dd00ca393 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:18:08 +0545 Subject: [PATCH 33/56] revert(openapi): drop /api/competition/chainhook path Phase 3.1 PR-C scope-cut. The path object pointed at a route that no longer exists; leaving it in the spec would mislead MCP clients (aibtcdev/aibtc-mcp-server#510) that codegen against the spec. The 'chainhook' value remains in the swaps.source enum at the data layer (migration 005); we just don't expose an HTTP receiver for it right now. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/openapi.json/route.ts | 62 ----------------------------------- 1 file changed, 62 deletions(-) diff --git a/app/api/openapi.json/route.ts b/app/api/openapi.json/route.ts index b2627184..14202050 100644 --- a/app/api/openapi.json/route.ts +++ b/app/api/openapi.json/route.ts @@ -1292,68 +1292,6 @@ export function GET() { }, }, }, - "/api/competition/chainhook": { - post: { - operationId: "submitCompetitionChainhook", - summary: "Receive a chainhook predicate firing (HMAC-authenticated)", - description: - "Receives Hiro chainhook predicate firings for the trading competition. The handler " + - "iterates `apply` and submits each tx to the verifier with `source='chainhook'`. " + - "Rollback entries are ignored — see lib/competition/chainhook.ts for the rationale.", - parameters: [ - { - name: "X-Chainhook-Signature", - in: "header", - required: false, - description: - "Hex HMAC-SHA256(env.CHAINHOOK_SECRET, request_body). Either this header or `Authorization: Bearer {hex}` must be present.", - schema: { type: "string" }, - }, - { - name: "Authorization", - in: "header", - required: false, - description: "`Bearer {hex}` form of the HMAC signature (Hiro controller default).", - schema: { type: "string" }, - }, - ], - requestBody: { - required: true, - content: { - "application/json": { - schema: { - type: "object", - description: - "Standard chainhook envelope with `apply` (and optional `rollback`) arrays of tx entries.", - }, - }, - }, - }, - responses: { - "200": { - description: "Batch processed — body is the ingestion summary", - content: { - "application/json": { - schema: { - type: "object", - properties: { - processed: { type: "integer" }, - inserted: { type: "integer" }, - alreadyKnown: { type: "integer" }, - rejected: { type: "integer" }, - pending: { type: "integer" }, - }, - }, - }, - }, - }, - "400": { description: "Malformed JSON or missing `apply` field" }, - "401": { description: "Missing or invalid signature" }, - "500": { description: "Server config error (CHAINHOOK_SECRET not set)" }, - "503": { description: "D1 temporarily unavailable" }, - }, - }, - }, "/api/competition/cron": { post: { operationId: "runCompetitionCron", From 5e85b199bea1803872a2f6fbc32bc2ccdda7eded Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:18:20 +0545 Subject: [PATCH 34/56] revert(llms): drop chainhook route + retune cron cadence in quick-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-C scope-cut + PR-D cadence retune. Two coupled edits in the same line: - Removed the chainhook entry (route no longer exists; the 'chainhook' enum value remains in migration 005). - Updated the cron description from 'nightly' to '15-min'. Cron now picks up the slack chainhook would have covered — agent-submit catches the live path, cron @ 15-min covers everything else within one quarter-hour of confirmation. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/llms.txt/route.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index e177c6e6..07862178 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -124,8 +124,7 @@ All endpoints return self-documenting JSON on GET. - GET /api/competition/status?address={stx} — membership + verified trade counts (unregistered → \`registered: false\`, not 404) - GET /api/competition/trades?address={stx}&limit=50&cursor=… — paginated swap history (keyset over burn_block_time, txid) - POST /api/competition/trades — submit a txid for verification (Hiro fetch + allowlist + INSERT OR IGNORE; 202 if pending, 200 if verified, 422 if rejected) -- POST /api/competition/chainhook — chainhook ingestion (HMAC-authenticated, operator-only) -- POST /api/competition/cron — nightly catch-up sweep (shared-secret, operator-only) +- POST /api/competition/cron — 15-min catch-up sweep (shared-secret, operator-only) ### Progression (Free) From 71347e9e7959ad43336df396b2f5241d670f89cf Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:18:58 +0545 Subject: [PATCH 35/56] revert(llms-full): drop chainhook section + retune cron to 15-min Phase 3.1 PR-C scope-cut + PR-D cadence retune. Three coupled edits: - Removed the 'Chainhook Ingestion' section (route no longer exists). - Updated the trading-comp section preamble: two ingestion paths (agent-submit + cron), and explicitly note the 'chainhook' enum value is reserved for a future real-time path. Keeps the schema-stability story coherent without leading callers to expect a receiver. - Changed cron description from 'nightly' to '15-min', with a line about agent-submit + cron jointly covering everything within a quarter hour of confirmation. - Schema-notes block updated to clarify which source values are written today vs reserved. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/llms-full.txt/route.ts | 40 +++++++++++++++----------------------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/app/llms-full.txt/route.ts b/app/llms-full.txt/route.ts index 85d40342..fe599bd7 100644 --- a/app/llms-full.txt/route.ts +++ b/app/llms-full.txt/route.ts @@ -868,16 +868,17 @@ See /api/openapi.json for complete response schemas. ## Trading Competition -Verifier surface for the AIBTC trading competition. Two read routes are live now -(Phase 3.1 PR-A); the POST verifier (single-txid Hiro fetch + INSERT OR IGNORE) -lands in Phase 3.1 PR-B. See issue #734 for the full plan; RFC under -\`docs/rfc-d1-schema.md\` §swaps. +Verifier surface for the AIBTC trading competition. Read + write routes are live +(Phase 3.1). See issue #734 for the full plan; RFC under \`docs/rfc-d1-schema.md\` §swaps. Data model: swaps are persisted to a D1 \`swaps\` table on terminal status only. -Pending/in-flight swaps are NOT stored (migration 005 forbids it). All ingestion -paths — agent-submit, nightly cron, real-time chainhook — converge on the same -row via INSERT OR IGNORE on \`txid\`. The \`source\` column records who got there -first. Mainnet-only in v1; no \`network\` parameter. +Pending/in-flight swaps are NOT stored (migration 005 forbids it). Two ingestion +paths converge on the same row via INSERT OR IGNORE on \`txid\`: agent-submit +(POST /api/competition/trades) and the 15-min catch-up cron. The \`source\` column +records who got there first. A third value \`'chainhook'\` is reserved in the enum +for a future real-time stream if/when product surfaces require sub-minute +freshness (current cadence is plenty for hourly leaderboards). Mainnet-only in +v1; no \`network\` parameter. ### Comp Status @@ -961,32 +962,23 @@ Response matrix: - \`502\` — Hiro upstream error; retryable. - \`503\` — D1 temporarily unavailable; retry per Retry-After header. -### Chainhook Ingestion (operator-only) - -\`POST /api/competition/chainhook\` receives Hiro chainhook predicate firings. -HMAC-authenticated via \`CHAINHOOK_SECRET\` (header \`X-Chainhook-Signature\` or -\`Authorization: Bearer {hex}\`). Iterates \`apply\` and submits each tx with -\`source='chainhook'\`. Rollback entries are ignored — the verifier persists -only terminal-status rows; a rolled-back tx simply never replays, and the -historical row stays in \`swaps\` as audit. - -Response is the ingestion summary: \`{ processed, inserted, alreadyKnown, rejected, pending }\`. - ### Cron Catch-Up (operator-only) -\`POST /api/competition/cron\` runs the nightly catch-up sweep. Walks +\`POST /api/competition/cron\` runs the 15-min catch-up sweep. Walks \`registered_wallets\` (100 addresses per run, resumes via \`comp:cron:cursor\` KV key), fetches each address's recent Hiro tx history, filters by allowlist, and submits matches with \`source='cron'\`. Shared-secret authenticated via -\`X-Cron-Secret\`. Defence in depth against chainhook gaps. +\`X-Cron-Secret\`. Picks up anything the agent-submit fast path missed within a +quarter hour of confirmation. Response: \`{ scanned, found, inserted, alreadyKnown, pending, rejected, cursor }\`. ### Schema Notes -- \`source\` enum: \`'agent' | 'cron' | 'chainhook'\`. The three values track which - ingestion path wrote the row first; idempotent re-submission from a different - source does NOT overwrite \`source\`. +- \`source\` enum: \`'agent' | 'cron' | 'chainhook'\`. \`'agent'\` and \`'cron'\` are + written today; \`'chainhook'\` is reserved for a future real-time path (no + receiver route in Phase 3.1 — the schema slot stays so we don't migrate later). + Idempotent re-submission from a different source does NOT overwrite \`source\`. - \`tx_status\` includes all terminal Stacks tx statuses (success + abort/dropped variants). Pending swaps don't get rows. - Field names mirror the migration (\`sender\`, \`token_in\`, \`amount_in\`, From bad0dd12857bfbb85668b64ab259c1c5f2f50dee Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:19:21 +0545 Subject: [PATCH 36/56] revert(agent-card): replace chainhook ingestion claim with 'reserved enum' Phase 3.1 PR-C scope-cut. The skill description used to advertise a three-source convergence model (agent + chainhook + cron); this PR ships only two, with 'chainhook' reserved as a schema-stable enum value for a future stream. The wording matters because A2A discovery callers (and the MCP server at aibtcdev/aibtc-mcp-server#510) read the skill description to decide what tools to expose. Promising a chainhook receiver we don't have would prompt MCPs to advertise an ingestion-monitoring tool that has no backend. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/.well-known/agent.json/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/.well-known/agent.json/route.ts b/app/.well-known/agent.json/route.ts index dbabe673..766406b4 100644 --- a/app/.well-known/agent.json/route.ts +++ b/app/.well-known/agent.json/route.ts @@ -378,7 +378,8 @@ export function GET() { "POST /api/competition/trades submits a Stacks txid for verification — server fetches via Hiro, " + "runs allowlist + sender checks, INSERT OR IGNOREs into the swaps table (first writer wins). " + "Pending txs return 202 and are tracked in KV with a 30-min TTL (no D1 row for pending). " + - "Three ingestion paths converge on the same row: agent-submit (this POST), chainhook, and nightly cron.", + "Two ingestion paths today: agent-submit (this POST) and a 15-min catch-up cron. " + + "A third 'chainhook' source value is reserved in the schema for a future real-time stream.", tags: ["competition", "trading", "swaps", "leaderboard"], examples: [ "Get my trading-comp status", From 4b3e072d859d524e21b1e5d273bd6bc37b30f9c1 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:19:44 +0545 Subject: [PATCH 37/56] chore(competition): retune cron docstring for 15-min cadence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-D cadence retune. Behaviour is unchanged — the cron function runs whenever it's invoked; the cadence is set by the external trigger. This commit just realigns the docstring with the operational decision to drop chainhook (separate commit) and run cron more frequently: - 15-min cadence instead of nightly. With ~430 registered wallets and a 100-addr-per-run cap, the full list cycles every ~5 runs (~75 min). Single Hiro client, no new infra. - Reframes the cron's role from 'defence in depth against chainhook' to 'second of two converging paths' (agent-submit + cron). Since chainhook is no longer in scope, the framing matters for the next reader. The 100-addr cap stays — it's the right Hiro-call budget for the new cadence; tighter is unnecessary, looser risks rate-limit hits. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/cron.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/competition/cron.ts b/lib/competition/cron.ts index 08619dc3..66697bc7 100644 --- a/lib/competition/cron.ts +++ b/lib/competition/cron.ts @@ -1,18 +1,19 @@ /** - * Nightly catch-up cron — walks registered_wallets and re-verifies recent + * 15-min catch-up cron — walks registered_wallets and re-verifies recent * Hiro tx history. Phase 3.1 PR-D. * - * Each ingestion path converges on the same `swaps` row via INSERT OR IGNORE - * on `txid`. The cron's job is *defence in depth* against chainhook gaps: - * if the chainhook controller went down or a predicate missed a firing, - * the next cron sweep will pick the tx up and persist it. + * Pairs with the agent-submit fast path (POST /api/competition/trades): + * agent-submit catches everything the agent does (the agent already knows + * its own txid); this cron catches everything the fast path missed. + * Both converge on the same `swaps` row via INSERT OR IGNORE on `txid` — + * first writer wins; second writer is a no-op. * - * Cost shape (handoff caps): - * - Max 100 addresses per execution + * Cost shape: + * - Max 100 addresses per execution. Tuned for a 15-min cadence: at the + * current ~430 registered wallets, the full list cycles in roughly + * 5 runs (~75 min). Single Hiro client, single rate-limit budget. * - Resume from KV cursor `comp:cron:cursor` so subsequent runs continue - * where the previous one stopped, walking the whole membership list - * across multiple cron firings rather than retrying the same head N - * times. + * where the previous one stopped rather than retrying the head N times. * * Returns a structured summary for the logs: * { scanned, found, inserted, alreadyKnown, rejected, pending, cursor } From 48c43856230e468278dbafe777db121a51b01416 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:20:07 +0545 Subject: [PATCH 38/56] chore(competition): cron route self-doc reflects 15-min cadence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 PR-D cadence retune. Updates the GET self-doc payload so operators hitting the route see the new framing — paired with the agent-submit fast path rather than 'defence in depth against chainhook', and per-run cap sized for the new cadence. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/competition/cron/route.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/api/competition/cron/route.ts b/app/api/competition/cron/route.ts index 07427cb7..b422bf9f 100644 --- a/app/api/competition/cron/route.ts +++ b/app/api/competition/cron/route.ts @@ -13,7 +13,7 @@ export async function GET() { endpoint: "/api/competition/cron", methods: ["POST"], description: - "Nightly catch-up sweep for the trading-comp verifier. Walks registered_wallets and re-fetches recent Hiro tx history, filtering by allowlist and submitting each match via verifyAndPersistSwap with source='cron'. Defence in depth against chainhook gaps.", + "15-min catch-up sweep for the trading-comp verifier. Walks registered_wallets and re-fetches recent Hiro tx history, filtering by allowlist and submitting each match via verifyAndPersistSwap with source='cron'. Pairs with POST /api/competition/trades (agent-submit fast path) — first writer wins on (txid).", auth: { scheme: "Shared secret", header: "X-Cron-Secret: {env.CRON_SECRET}", @@ -28,7 +28,8 @@ export async function GET() { cursor: "string | null (next stx_address to resume from)", }, notes: [ - "Per-run cap: 100 addresses (CRON_MAX_ADDRESSES_PER_RUN). The sweep resumes across runs via the comp:cron:cursor KV key.", + "Per-run cap: 100 addresses (CRON_MAX_ADDRESSES_PER_RUN). Sized for a 15-min cadence — the full membership cycles in roughly 5 runs at the current scale.", + "The sweep resumes across runs via the comp:cron:cursor KV key.", "wrangler cron-trigger wiring is tracked as a follow-up; this route is callable today via HTTPS with the shared secret.", ], }, From 65f2c34b354c39e361e34a6bc1377b294574e4d2 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:20:19 +0545 Subject: [PATCH 39/56] chore(openapi): cron summary + description reflect 15-min cadence Phase 3.1 PR-D cadence retune. MCP clients (aibtcdev/aibtc-mcp-server#510) codegen from this spec; updating it keeps tool descriptions accurate without a separate handshake. Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/openapi.json/route.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/api/openapi.json/route.ts b/app/api/openapi.json/route.ts index 14202050..46f945f6 100644 --- a/app/api/openapi.json/route.ts +++ b/app/api/openapi.json/route.ts @@ -1295,11 +1295,13 @@ export function GET() { "/api/competition/cron": { post: { operationId: "runCompetitionCron", - summary: "Run the nightly catch-up sweep (shared-secret authenticated)", + summary: "Run the 15-min catch-up sweep (shared-secret authenticated)", description: "Walks registered_wallets, fetches recent Hiro tx history per address, filters by allowlist, " + - "and submits each match via the verifier with `source='cron'`. Per-run cap: 100 addresses; " + - "resumes across runs via the `comp:cron:cursor` KV key.", + "and submits each match via the verifier with `source='cron'`. Pairs with the agent-submit fast " + + "path (POST /api/competition/trades) — first writer wins on `(txid)`. Per-run cap: 100 " + + "addresses, sized for a 15-min cadence at the current ~430 registered wallets. Resumes across " + + "runs via the `comp:cron:cursor` KV key.", parameters: [ { name: "X-Cron-Secret", From 9d354f3cc5119f895adcffc0f05ce59d331cd7b6 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:21:38 +0545 Subject: [PATCH 40/56] fix(competition): recognize Hiro 'stx_asset' event_type for native STX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit block-on-merge regression caught in PR #738 review by @secret-mars. Verified empirically against canonical Bitflow tx 0x46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4 — the parser was emitting token_in='unknown' for every STX swap. Root cause: - lib/competition/parse.ts checked event_type ∈ {stx_transfer_event, stx_transfer}. - Hiro mainnet /extended/v1/tx/{txid} actually returns event_type= 'stx_asset' for STX transfers (verified with curl + jq on the canonical txid in the review thread). - The check fell through to `a.asset_id ?? 'unknown'`, and STX events in Hiro responses do NOT carry an asset_id — so every STX leg landed in D1 as token_in='unknown' or token_out='unknown'. Why the tests didn't catch it: - All test fixtures used event_type='stx_transfer_event' — same wrong string as the production check. The bug was dual-coded into the test data and the production code, so test green ≠ bug-free. Separate commit corrects the fixtures. Fix: - Extracted an exported STX_EVENT_TYPES Set covering all three known vocabularies: 'stx_asset' (Hiro mainnet /extended/v1), the older 'stx_transfer_event', and 'stx_transfer' (downstream tooling). - Replaced the OR-chain with STX_EVENT_TYPES.has(ev.event_type ?? ''). Schema impact: - swaps.token_in / swaps.token_out for STX-side rows persisted before this fix would land as 'unknown'. (txid)-PK + INSERT OR IGNORE means the verifier won't re-parse on re-submission of the same txid. Per the review, that's a one-time post-merge UPDATE if needed; deploying the fix pre-merge means no row #1 pollution. Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/parse.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/competition/parse.ts b/lib/competition/parse.ts index 1ad74c48..0ad5c9ad 100644 --- a/lib/competition/parse.ts +++ b/lib/competition/parse.ts @@ -30,6 +30,23 @@ import { PROVIDER_ATTRIBUTION_CONTRACTS } from "./allowlist"; */ export const STX_ASSET_ID = "stx"; +/** + * Hiro `event_type` values that identify an STX transfer event. + * + * The mainnet `/extended/v1/tx/{txid}` endpoint returns `stx_asset` today + * (verified against tx `0x46bc5587…f0ee0e4` — Bitflow stableswap-stx-ststx). + * The older blockchain-api and some downstream tooling emit + * `stx_transfer_event` / `stx_transfer`; both are kept here so the parser + * stays correct if we read from a different Hiro version or a self-hosted + * indexer. STX events in the Hiro response do NOT carry an `asset_id`, so + * we synthesize STX_ASSET_ID from the event_type discriminator. + */ +const STX_EVENT_TYPES: ReadonlySet = new Set([ + "stx_asset", + "stx_transfer_event", + "stx_transfer", +]); + /** Minimal contract-call shape from the Hiro tx response. */ export interface HiroContractCall { contract_id: string; @@ -146,7 +163,7 @@ export function parseSwapFromTx(tx: HiroTxForSwap): ParseResult { } const assetId = - ev.event_type === "stx_transfer_event" || ev.event_type === "stx_transfer" + STX_EVENT_TYPES.has(ev.event_type ?? "") ? STX_ASSET_ID : a.asset_id ?? "unknown"; From 2cde748b6bb0cad8713e0179767d35606d1d13d4 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:21:48 +0545 Subject: [PATCH 41/56] test(competition): align parse fixtures with real Hiro 'stx_asset' string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the parse.ts fix. The previous fixtures used event_type='stx_transfer_event' — same wrong string as the prod check, so test green ≠ bug-free. Two changes: - Bulk-rename existing 8 fixtures from 'stx_transfer_event' to 'stx_asset' (the real Hiro mainnet value). - Add a new it.each block exercising all three event_type variants in STX_EVENT_TYPES so the Set is explicitly under test, not just the one canonical string. If Hiro changes the value again, the regression surface is documented + checked. Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/__tests__/parse.test.ts | 46 ++++++++++++++++++++----- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/lib/competition/__tests__/parse.test.ts b/lib/competition/__tests__/parse.test.ts index 8d5a93ab..aaa9cae0 100644 --- a/lib/competition/__tests__/parse.test.ts +++ b/lib/competition/__tests__/parse.test.ts @@ -45,7 +45,7 @@ describe("parseSwapFromTx — stableswap (simple two-leg)", () => { events: [ { event_index: 0, - event_type: "stx_transfer_event", + event_type: "stx_asset", asset: { asset_event_type: "transfer", sender: AGENT, @@ -96,7 +96,7 @@ describe("parseSwapFromTx — stableswap (simple two-leg)", () => { }, }, { - event_type: "stx_transfer_event", + event_type: "stx_asset", asset: { asset_event_type: "transfer", sender: POOL, @@ -130,7 +130,7 @@ describe("parseSwapFromTx — xyk multi-hop", () => { events: [ // Agent sends STX to hop1 { - event_type: "stx_transfer_event", + event_type: "stx_asset", asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1000000" }, }, // Intermediate hop ALEX → agent (small amount, should NOT be picked) @@ -178,7 +178,7 @@ describe("parseSwapFromTx — PR-E provider attribution", () => { }, events: [ { - event_type: "stx_transfer_event", + event_type: "stx_asset", asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1000000" }, }, { @@ -206,7 +206,7 @@ describe("parseSwapFromTx — PR-E provider attribution", () => { }, events: [ { - event_type: "stx_transfer_event", + event_type: "stx_asset", asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1000000" }, }, { @@ -244,7 +244,7 @@ describe("parseSwapFromTx — rejection paths", () => { const tx = baseTx({ events: [ { - event_type: "stx_transfer_event", + event_type: "stx_asset", asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1000000" }, }, ], @@ -259,7 +259,7 @@ describe("parseSwapFromTx — rejection paths", () => { const tx = baseTx({ events: [ { - event_type: "stx_transfer_event", + event_type: "stx_asset", asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "not-a-number" }, }, ], @@ -271,12 +271,42 @@ describe("parseSwapFromTx — rejection paths", () => { }); }); +describe("parseSwapFromTx — STX event_type variants (Hiro vocabulary)", () => { + // Regression for the bug @secret-mars caught in PR #738 review: the Hiro + // mainnet /extended/v1/tx/{txid} endpoint returns `stx_asset`, but older + // tooling uses `stx_transfer_event` / `stx_transfer`. All three must + // resolve to token_in = STX_ASSET_ID (not `unknown`). + it.each([ + ["stx_asset"], // Hiro mainnet /extended/v1 + ["stx_transfer_event"], // older blockchain-api emissions + ["stx_transfer"], // some downstream tooling + ])("recognizes event_type=%s as native STX", (eventType) => { + const tx = baseTx({ + events: [ + { + event_type: eventType, + asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1000000" }, + }, + { + event_type: "ft_transfer_event", + asset: { asset_event_type: "transfer", sender: POOL, recipient: AGENT, amount: "859839", asset_id: STSTX }, + }, + ], + }); + const result = parseSwapFromTx(tx); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.swap.token_in).toBe(STX_ASSET_ID); + expect(result.swap.amount_in).toBe(1000000); + }); +}); + describe("parseSwapFromTx — audit blob shape", () => { it("records both legs in raw_event_json so a reviewer can trace amounts back to events", () => { const tx = baseTx({ events: [ { - event_type: "stx_transfer_event", + event_type: "stx_asset", asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1000000" }, }, { From 5f43f6f2c3ff193da5a97355a99b787f6213ad71 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:21:56 +0545 Subject: [PATCH 42/56] test(competition): align verify fixture with real Hiro 'stx_asset' string Companion to the parse.ts fix. verify.test.ts buildHappyTx() used the same stale 'stx_transfer_event' string; now uses 'stx_asset' matching what Hiro mainnet actually returns. Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/__tests__/verify.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/competition/__tests__/verify.test.ts b/lib/competition/__tests__/verify.test.ts index 822e7389..50ce7557 100644 --- a/lib/competition/__tests__/verify.test.ts +++ b/lib/competition/__tests__/verify.test.ts @@ -58,7 +58,7 @@ function buildHappyTx() { events: [ { event_index: 0, - event_type: "stx_transfer_event", + event_type: "stx_asset", asset: { asset_event_type: "transfer", sender: AGENT, recipient: POOL, amount: "1000000" }, }, { From 5e02a8da34e73fa193eda767b28b0d92f01ac6d5 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 10:22:30 +0545 Subject: [PATCH 43/56] docs(competition): tighten self-doc wording on agent_id field Non-blocking nit from PR #738 review (@secret-mars). The previous 'null until minted' could be read as 'null until someone mints any identity NFT' rather than 'null until this specific agent registers theirs'. Replaces with 'null until the agent registers their identity NFT' to remove the ambiguity. Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/competition/status/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/competition/status/route.ts b/app/api/competition/status/route.ts index d6b58dcb..07252f50 100644 --- a/app/api/competition/status/route.ts +++ b/app/api/competition/status/route.ts @@ -34,7 +34,7 @@ function selfDocResponse() { }, responseFormat: { address: "string (STX address)", - agent_id: "number | null (ERC-8004 identity NFT id; null until minted)", + agent_id: "number | null (ERC-8004 identity NFT id; null until the agent registers their identity NFT)", registered: "boolean (is the address a registered AIBTC agent)", trade_count: "number (total swaps recorded for this sender)", verified_trade_count: "number (swaps with tx_status='success')", From 4a91a804e62df882bf8854392604710e5ea41fb4 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 12:00:44 +0545 Subject: [PATCH 44/56] fix(competition): drop pending-cache short-circuit on POST /trades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @secret-mars caught this empirically on the preview deploy (PR #738 comment 4418003085): the cache-hit short-circuit at the top of POST /api/competition/trades created a 30-min blind window after a tx confirmed. Re-submits of a now-confirmed tx hit the KV cache, returned 202 without invoking the verifier, and the row never landed in `swaps`. Reproduction from secret-mars's run (txid fa62f847…baf3c1): 05:48Z POST → pending → wrote comp:pending:{txid} → 202 05:52Z Stacks confirmed the tx (tx_status=success at block 7929497) 05:52Z POST same txid → CACHE HIT → 202 (verifier skipped) 05:53Z POST same txid → CACHE HIT → 202 (verifier skipped) `/api/competition/status?address=…` stayed at verified_trade_count=0 because no row was ever written. Cron is the bypass path (15-min sweep would eventually catch it via source='cron') but cron-trigger wiring isn't on preview, so end-to-end the verification path was effectively dead from the agent's side. Fix: remove the read+short-circuit at the request path. Verifier is now ALWAYS invoked. The KV key (`comp:pending:{txid}`, 30-min TTL) is downgraded from a request-gate to an observability artifact: - Still written when verifyAndPersistSwap returns pending. - Still cleared when it returns verified. - Nothing reads it from the request path. Why the cache was redundant: the actual hammer-protection on this route is the rate limit (20/min per IP via RATE_LIMIT_MUTATING). The cache added nothing the rate limit doesn't already cover, while actively preventing the legitimate retry-after-confirmation path. Hiro free tier is 50 req/sec — well above the rate-limit ceiling. The verifier already short-circuits on idempotent re-submission via its own readSwap() check before INSERT OR IGNORE, so a re-POST of an already-verified tx is cheap (one D1 read, no Hiro fetch). Refs PR #738 review by @secret-mars Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/competition/trades/route.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app/api/competition/trades/route.ts b/app/api/competition/trades/route.ts index 5abf26b6..b8a8f5f3 100644 --- a/app/api/competition/trades/route.ts +++ b/app/api/competition/trades/route.ts @@ -288,20 +288,19 @@ export async function POST(request: NextRequest) { ); } - // KV pending tracker — if the verifier upstream already queued this txid - // we short-circuit and return 202 immediately so the caller doesn't - // re-hit Hiro on every retry. + // KV pending tracker — the `comp:pending:{txid}` key is an observability + // artifact (set when verifyAndPersistSwap returns pending, cleared on + // verified) so cron/admin tooling can see which txids are mid-flight. + // It is NOT a request-path short-circuit: an earlier version of this + // route read the key and returned 202 without invoking the verifier, + // which created a 30-min blind window after a tx confirmed — re-submits + // of a now-confirmed tx returned 202 forever and the row never landed + // in `swaps`. Empirically reproduced by @secret-mars on the preview + // deploy (PR #738 comment 4418003085). Rate-limit (20/min per IP) is the + // actual hammer-protection; the cache adds nothing the rate limit doesn't + // already do. const kv = env.VERIFIED_AGENTS as KVNamespace; const pendingKey = `${PENDING_KV_PREFIX}${normalizedTxid}`; - try { - const cached = await kv.get(pendingKey); - if (cached) { - return NextResponse.json({ accepted: true }, { status: 202 }); - } - } catch (err) { - // KV read failure is non-fatal — the verifier will refetch from Hiro. - logger.warn("Pending-tx KV read failed", { error: String(err) }); - } const result = await verifyAndPersistSwap(env, db, normalizedTxid, "agent", logger); @@ -315,8 +314,9 @@ export async function POST(request: NextRequest) { } if (result.status === "verified") { - // Clear the pending-tx marker — once we have a terminal row we don't - // want subsequent submissions short-circuiting on the stale flag. + // Clear the pending-tx marker — observability hygiene only; the key has + // no behavioural effect on future requests after the read-side + // short-circuit was removed. try { await kv.delete(pendingKey); } catch { From 45b3c1982dcb64487bb1d1bfc448cc1172686302 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 12:00:54 +0545 Subject: [PATCH 45/56] test(competition): update POST tests for dropped pending-cache short-circuit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the route fix. The previous test asserted the short-circuit behaviour ('short-circuits to 202 when comp:pending exists in KV (no Hiro fetch)') — that assertion would now fail because the route always invokes the verifier. Replaced with two regression tests that lock in the new contract: 1. 'invokes verifier on every submit even when comp:pending exists' — when the cache has a marker AND the verifier returns 'verified' (i.e. the tx confirmed in the meantime), the route returns 200 with the row, not 202. This is the exact secret-mars repro scenario inverted. 2. 'does NOT read the pending key from the request path' — explicit assertion that kv.get is never called from POST. Guards against the short-circuit being re-introduced silently. Kept the two existing tests that exercise the write-on-pending and delete-on-verified paths — those behaviours are unchanged; only the read-side gate was removed. Refs PR #738 review by @secret-mars Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/post-verifier.test.ts | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/app/api/competition/__tests__/post-verifier.test.ts b/app/api/competition/__tests__/post-verifier.test.ts index da9ce2ee..336e8449 100644 --- a/app/api/competition/__tests__/post-verifier.test.ts +++ b/app/api/competition/__tests__/post-verifier.test.ts @@ -7,7 +7,9 @@ * - 400 on malformed body / bad txid * - 429 + Retry-After when RATE_LIMIT_MUTATING trips * - 503 + Retry-After when D1 binding is missing - * - 202 + Retry-After when KV pending marker exists (short-circuit) + * - 202 when verify returns pending (KV marker is written as observability, + * NOT used as a request-path short-circuit — see secret-mars's PR #738 + * finding for the empirical reproduction) * - 202 + KV write when verify returns pending * - 200 + row when verify returns verified * - 422 on verifier rejections (sender/allowlist/parse) @@ -127,17 +129,42 @@ describe("POST /api/competition/trades — rate limit + binding gates", () => { }); describe("POST /api/competition/trades — pending tracker (KV)", () => { - it("short-circuits to 202 when comp:pending:{txid} exists in KV (no Hiro fetch)", async () => { + // Regression for secret-mars's PR #738 finding (comment 4418003085). + // The earlier short-circuit read comp:pending:{txid} and returned 202 + // without invoking the verifier, creating a 30-min blind window after + // a tx confirmed. Re-submits of a now-confirmed tx returned 202 forever + // and the row never landed in `swaps`. Verifier is now ALWAYS invoked. + it("invokes verifier on every submit even when comp:pending:{txid} exists in KV", async () => { const kv = makeKv({ get: vi.fn().mockResolvedValue("1") }); mockEnv({ kv }); + (verifyAndPersistSwap as Mock).mockResolvedValue({ + status: "verified", + inserted: true, + row: { + txid: TXID, + sender: "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", + contract_id: "x", + function_name: "y", + token_in: "stx", + amount_in: 1, + token_out: "stx", + amount_out: 1, + burn_block_time: 1, + tx_status: "success", + source: "agent", + scored_value: null, + scored_at: null, + }, + }); const res = await POST(buildRequest({ txid: TXID })); - expect(res.status).toBe(202); - const body = await res.json(); - expect(body).toEqual({ accepted: true }); - expect(verifyAndPersistSwap).not.toHaveBeenCalled(); + // Verifier ran and returned a confirmed row — not the cached 202. + expect(res.status).toBe(200); + expect(verifyAndPersistSwap).toHaveBeenCalledTimes(1); + // The pending marker is cleared since the tx is now verified. + expect(kv.delete).toHaveBeenCalledWith(`comp:pending:${TXID}`); }); - it("writes the pending KV marker when verify returns pending", async () => { + it("writes the pending KV marker when verify returns pending (observability artifact)", async () => { const kv = makeKv(); mockEnv({ kv }); (verifyAndPersistSwap as Mock).mockResolvedValue({ status: "pending" }); @@ -147,7 +174,7 @@ describe("POST /api/competition/trades — pending tracker (KV)", () => { expect((kv.put as Mock).mock.calls[0][2]).toEqual({ expirationTtl: 30 * 60 }); }); - it("clears the pending KV marker on a verified result", async () => { + it("clears the pending KV marker on a verified result (observability hygiene)", async () => { const kv = makeKv(); mockEnv({ kv }); (verifyAndPersistSwap as Mock).mockResolvedValue({ @@ -173,6 +200,18 @@ describe("POST /api/competition/trades — pending tracker (KV)", () => { expect(res.status).toBe(200); expect(kv.delete).toHaveBeenCalledWith(`comp:pending:${TXID}`); }); + + it("does NOT read the pending key from the request path (no short-circuit)", async () => { + const kv = makeKv({ get: vi.fn().mockResolvedValue("1") }); + mockEnv({ kv }); + (verifyAndPersistSwap as Mock).mockResolvedValue({ status: "pending" }); + await POST(buildRequest({ txid: TXID })); + // The route must not gate on the cached marker. We tolerate other KV + // reads from unrelated code paths (none exist today) but a positive + // hit here would have indicated the bug. + const getCalls = (kv.get as Mock).mock.calls; + expect(getCalls.length).toBe(0); + }); }); describe("POST /api/competition/trades — verify result → HTTP mapping", () => { From 16db030a7d16e67cf2015e11615c2353bdcb5cac Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 12:16:31 +0545 Subject: [PATCH 46/56] fix(competition): readSwap before Hiro fetch in verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder so the D1 idempotency check runs first. On re-submits of an already-verified txid (the common duplicate case after secret-mars's PR #738 finding), the verifier returns `{ inserted: false, row }` without making a Hiro round-trip — saves the wasted upstream call AND lets the route layer return 409 Conflict promptly. Previously: 1. fetchTxFromHiro ← always paid 2. terminal-status branch 3. readSwap (idempotency) 4. sender / allowlist gates 5. INSERT OR IGNORE Now: 1. readSwap (idempotency) ← short-circuits duplicates here 2. fetchTxFromHiro 3. terminal-status branch 4. sender / allowlist gates 5. INSERT OR IGNORE The Hiro fetch result wasn't compared against the existing row in the old order — the post-fetch readSwap just returned the existing row unconditionally. So this is a strict improvement: same semantics on the hit path, no Hiro call on duplicates. Existing tests pass without modification — the verify.test.ts D1 mock dispatches on SQL keyword, not on call order. Refs PR #738 review by @secret-mars Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/verify.ts | 43 +++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/lib/competition/verify.ts b/lib/competition/verify.ts index 0f4dcbb7..f68a0562 100644 --- a/lib/competition/verify.ts +++ b/lib/competition/verify.ts @@ -224,26 +224,12 @@ export async function verifyAndPersistSwap( source: SwapRow["source"], logger?: Logger ): Promise { - const fetchRes = await fetchTxFromHiro(env, txid, logger); - if (!fetchRes.ok) { - return { status: "rejected", code: fetchRes.code, reason: fetchRes.reason }; - } - const tx = fetchRes.tx; - - if (PENDING_STATUSES.has(tx.tx_status)) { - return { status: "pending" }; - } - if (!TERMINAL_STATUSES.has(tx.tx_status)) { - return { - status: "rejected", - code: "malformed_tx", - reason: `Unknown tx_status '${tx.tx_status}'`, - }; - } - - // Idempotent re-submission shortcut: if the row already exists, return it. - // Saves a redundant parse + INSERT OR IGNORE roundtrip when another path - // (chainhook / cron) wrote the row first. + // D1 readSwap runs FIRST — if the row already exists (idempotent + // re-submission OR another ingestion path wrote it), short-circuit + // before the Hiro fetch. Saves the wasted upstream call on every + // duplicate submit and lets the route layer return 409 Conflict + // promptly. The route is responsible for translating + // { inserted: false } into a 409 with the existing_row payload. let existing: SwapRow | null = null; try { existing = await readSwap(db, txid); @@ -262,6 +248,23 @@ export async function verifyAndPersistSwap( return { status: "verified", inserted: false, row: existing }; } + const fetchRes = await fetchTxFromHiro(env, txid, logger); + if (!fetchRes.ok) { + return { status: "rejected", code: fetchRes.code, reason: fetchRes.reason }; + } + const tx = fetchRes.tx; + + if (PENDING_STATUSES.has(tx.tx_status)) { + return { status: "pending" }; + } + if (!TERMINAL_STATUSES.has(tx.tx_status)) { + return { + status: "rejected", + code: "malformed_tx", + reason: `Unknown tx_status '${tx.tx_status}'`, + }; + } + // Sender + allowlist gates. The allowlist check depends on a parsed // contract_call — fail fast if the tx isn't a contract call at all. if (tx.tx_type !== "contract_call" || !tx.contract_call) { From 64cb39df0f9948e5cea143c02b4f696849e1d3dd Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 12:16:45 +0545 Subject: [PATCH 47/56] fix(competition): 409 on duplicate txid + drop KV pending machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled changes to POST /api/competition/trades: ## 1. 409 Conflict on idempotent re-submit Before: re-submits of an already-verified txid returned 200 with a byte-identical body to the first-time write. Callers had no way to distinguish "I wrote this row just now" from "this row already existed before I submitted." secret-mars flagged the UX gap on PR #738; biwasxyz reframed it: re-submitting the same txid is an error that the caller should know about. Now: when verifyAndPersistSwap returns `{ inserted: false, row }`, the route returns: 409 Conflict { "error": "Transaction already verified for this competition", "code": "txid_already_verified", "retryable": false, "existing_row": { ...the persisted row... } } The existing_row.source identifies which ingestion path wrote first (agent or cron — chainhook is reserved in the enum but no receiver route ships in this PR). retryable:false because re-POSTing the same txid will keep landing in this branch. The 200 path is now reserved for first-time writes only — body is the persisted SwapRow alone (no metadata fields). Wire contract stays clean. ## 2. Drop KV pending machinery The MCP server now pre-checks tx confirmation before submitting, so the 30-min KV pending tracker is dead weight on the happy path. Removed: - PENDING_KV_PREFIX + PENDING_KV_TTL_SECONDS constants - kv.put on `result.status === "pending"` - kv.delete on verified - All KV references from the POST handler The 202 response shape is kept as a thin fallback for the racy edge case where the MCP pre-check sees the tx as confirmed but our Hiro fetch hasn't propagated yet (block just mined). Body: `{ accepted: true, note: "Hiro has not yet propagated this tx as terminal. Retry in a few seconds." }`. No D1 row is written — migration 005 forbids pending rows. Self-doc payload (`?docs=1`) updated to document both changes. Refs PR #738 review by @secret-mars + operator clarification Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/competition/trades/route.ts | 70 +++++++++++++++-------------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/app/api/competition/trades/route.ts b/app/api/competition/trades/route.ts index b8a8f5f3..0a32f8eb 100644 --- a/app/api/competition/trades/route.ts +++ b/app/api/competition/trades/route.ts @@ -14,10 +14,6 @@ import { } from "@/lib/competition/d1-reads"; import { verifyAndPersistSwap } from "@/lib/competition/verify"; -/** KV key + TTL for the pending-tx tracker. Migration 005 forbids a 'pending' - * row in `swaps`, so the verifier upstream uses KV with a short TTL. */ -const PENDING_KV_PREFIX = "comp:pending:"; -const PENDING_KV_TTL_SECONDS = 30 * 60; const TXID_RE = /^(0x)?[0-9a-fA-F]{64}$/; const RATE_LIMIT_RETRY_AFTER = 60; @@ -80,13 +76,14 @@ function selfDocResponse() { }, post: { description: - "Submit a Stacks txid for verification. The server fetches the tx from Hiro, runs sender + allowlist checks, parses the swap, and persists via INSERT OR IGNORE.", + "Submit a confirmed Stacks txid for verification. Callers (typically the AIBTC MCP server) must pre-check that the tx is terminal before submitting; the route checks D1 first (cheap idempotency gate), then fetches the tx from Hiro, runs sender + allowlist checks, parses the swap, and persists via INSERT OR IGNORE. First writer wins on `(txid)` across all ingestion paths (agent / cron); re-submits of an already-recorded txid return 409.", requestBody: { txid: "string — 64-char hex (0x-prefixed accepted)" }, responses: { - "200": "Verified — body is the persisted SwapRow", - "202": "Pending — tx not yet confirmed. { accepted: true }; re-poll later. (Pending state is KV-tracked with a 30-min TTL; not in D1.)", + "200": "First-time verified — body is the persisted SwapRow", + "202": "Pending fallback — should be rare since callers pre-check confirmation. Indicates Hiro has not yet propagated this tx as terminal (block just mined). Body: { accepted: true, note }. Retry in a few seconds.", "400": "Malformed txid", "404": "Hiro could not find the txid", + "409": "Transaction already verified — this txid is already in the swaps table. Body: { error, code: 'txid_already_verified', retryable: false, existing_row }. The existing_row.source identifies which ingestion path wrote first.", "422": "Sender not registered, contract not on allowlist, parse failure, or terminal failure status", "429": "Rate limited — Retry-After header set", "502": "Upstream (Hiro) error — retryable", @@ -288,40 +285,45 @@ export async function POST(request: NextRequest) { ); } - // KV pending tracker — the `comp:pending:{txid}` key is an observability - // artifact (set when verifyAndPersistSwap returns pending, cleared on - // verified) so cron/admin tooling can see which txids are mid-flight. - // It is NOT a request-path short-circuit: an earlier version of this - // route read the key and returned 202 without invoking the verifier, - // which created a 30-min blind window after a tx confirmed — re-submits - // of a now-confirmed tx returned 202 forever and the row never landed - // in `swaps`. Empirically reproduced by @secret-mars on the preview - // deploy (PR #738 comment 4418003085). Rate-limit (20/min per IP) is the - // actual hammer-protection; the cache adds nothing the rate limit doesn't - // already do. - const kv = env.VERIFIED_AGENTS as KVNamespace; - const pendingKey = `${PENDING_KV_PREFIX}${normalizedTxid}`; - const result = await verifyAndPersistSwap(env, db, normalizedTxid, "agent", logger); + // Pending fallback: the MCP server pre-checks tx confirmation before + // submitting, so this branch should be unreachable on the happy path. + // It survives as defense in depth for the racy edge case where the + // MCP saw the tx as confirmed but our Hiro fetch hasn't propagated + // yet (block just mined). Caller should retry shortly. No D1 row is + // written — migration 005 forbids 'pending' rows in `swaps`. if (result.status === "pending") { - try { - await kv.put(pendingKey, "1", { expirationTtl: PENDING_KV_TTL_SECONDS }); - } catch (err) { - logger.warn("Pending-tx KV write failed", { error: String(err) }); - } - return NextResponse.json({ accepted: true }, { status: 202 }); + return NextResponse.json( + { + accepted: true, + note: "Hiro has not yet propagated this tx as terminal. Retry in a few seconds.", + }, + { status: 202 } + ); } if (result.status === "verified") { - // Clear the pending-tx marker — observability hygiene only; the key has - // no behavioural effect on future requests after the read-side - // short-circuit was removed. - try { - await kv.delete(pendingKey); - } catch { - // best-effort + // Idempotent re-submit: the row already existed before this POST hit + // the verifier. Return 409 Conflict (not 200) so the caller has an + // unambiguous signal that this submit did NOT write the row. The + // existing row is included so callers can reconcile (its `source` + // identifies which ingestion path wrote first — agent / cron / + // chainhook). retryable: false because re-POSTing the same txid will + // keep landing here. + if (!result.inserted) { + return NextResponse.json( + { + error: "Transaction already verified for this competition", + code: "txid_already_verified", + retryable: false, + existing_row: result.row, + }, + { status: 409 } + ); } + + // First-time successful write. Body is the persisted SwapRow. return NextResponse.json(result.row, { status: 200 }); } From 067a12da4ae16d99ff9ccb5f90f3a19ac49331d4 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 12:17:00 +0545 Subject: [PATCH 48/56] test(competition): 409 path + lifecycle sequence + drop KV-tracker tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion test updates for the route changes: ## New tests - "returns 409 + existing_row when inserted:false (row already in D1)" — the core duplicate-detection assertion. - "includes the row.source in existing_row so callers know which ingestion path won" — the source: 'cron' case is the realistic scenario (cron beat agent-submit to the punch); test pins that the field is exposed. - "4-stage lifecycle: pending → pending → verified (200) → re-submit (409)" — the chained-POST scenario biwasxyz asked about. Asserts that all four states traverse correctly, the 200 body is the row alone (no error/ existing_row fields), and the 409 body has the full structured shape. Verifier is invoked exactly 4 times — no request-path short-circuit silently skips any call. ## Dropped tests - "short-circuits to 202 when comp:pending exists" was already replaced in an earlier commit; further KV-tracker assertions (write-on-pending, delete-on-verified, no-read-from-request-path) are now obsolete because the route no longer touches KV at all. Replaced with: - "does NOT touch KV on any submit (KV pending machinery was removed)" — explicit guard that kv.get / kv.put / kv.delete are never called from POST. Prevents the machinery from creeping back in silently. - "returns 202 with note when verify returns pending (Hiro propagation race)" — the new shape of the pending fallback (with explanatory note). Final count: 17 tests (was 16; +5 new, -4 obsolete). Refs PR #738 review by @secret-mars Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/post-verifier.test.ts | 167 ++++++++++-------- 1 file changed, 97 insertions(+), 70 deletions(-) diff --git a/app/api/competition/__tests__/post-verifier.test.ts b/app/api/competition/__tests__/post-verifier.test.ts index 336e8449..e630dfd7 100644 --- a/app/api/competition/__tests__/post-verifier.test.ts +++ b/app/api/competition/__tests__/post-verifier.test.ts @@ -7,9 +7,11 @@ * - 400 on malformed body / bad txid * - 429 + Retry-After when RATE_LIMIT_MUTATING trips * - 503 + Retry-After when D1 binding is missing - * - 202 when verify returns pending (KV marker is written as observability, - * NOT used as a request-path short-circuit — see secret-mars's PR #738 - * finding for the empirical reproduction) + * - 202 fallback when verify returns pending (MCP pre-checks confirmation + * so this should be unreachable on the happy path; covers Hiro + * propagation race only) + * - 409 Conflict on idempotent re-submit (the row already exists in D1) + * — see secret-mars's PR #738 finding (comment 4418003085) * - 202 + KV write when verify returns pending * - 200 + row when verify returns verified * - 422 on verifier rejections (sender/allowlist/parse) @@ -128,89 +130,114 @@ describe("POST /api/competition/trades — rate limit + binding gates", () => { }); }); -describe("POST /api/competition/trades — pending tracker (KV)", () => { - // Regression for secret-mars's PR #738 finding (comment 4418003085). - // The earlier short-circuit read comp:pending:{txid} and returned 202 - // without invoking the verifier, creating a 30-min blind window after - // a tx confirmed. Re-submits of a now-confirmed tx returned 202 forever - // and the row never landed in `swaps`. Verifier is now ALWAYS invoked. - it("invokes verifier on every submit even when comp:pending:{txid} exists in KV", async () => { - const kv = makeKv({ get: vi.fn().mockResolvedValue("1") }); +describe("POST /api/competition/trades — pending fallback (no KV writes)", () => { + it("returns 202 with note when verify returns pending (Hiro propagation race)", async () => { + const kv = makeKv(); mockEnv({ kv }); - (verifyAndPersistSwap as Mock).mockResolvedValue({ - status: "verified", - inserted: true, - row: { - txid: TXID, - sender: "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", - contract_id: "x", - function_name: "y", - token_in: "stx", - amount_in: 1, - token_out: "stx", - amount_out: 1, - burn_block_time: 1, - tx_status: "success", - source: "agent", - scored_value: null, - scored_at: null, - }, - }); + (verifyAndPersistSwap as Mock).mockResolvedValue({ status: "pending" }); const res = await POST(buildRequest({ txid: TXID })); - // Verifier ran and returned a confirmed row — not the cached 202. - expect(res.status).toBe(200); - expect(verifyAndPersistSwap).toHaveBeenCalledTimes(1); - // The pending marker is cleared since the tx is now verified. - expect(kv.delete).toHaveBeenCalledWith(`comp:pending:${TXID}`); + expect(res.status).toBe(202); + const body = await res.json(); + expect(body.accepted).toBe(true); + expect(body.note).toMatch(/propagated/i); }); - it("writes the pending KV marker when verify returns pending (observability artifact)", async () => { + it("does NOT touch KV on any submit (KV pending machinery was removed)", async () => { const kv = makeKv(); mockEnv({ kv }); (verifyAndPersistSwap as Mock).mockResolvedValue({ status: "pending" }); + await POST(buildRequest({ txid: TXID })); + expect(kv.get).not.toHaveBeenCalled(); + expect(kv.put).not.toHaveBeenCalled(); + expect(kv.delete).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/competition/trades — idempotent re-submit → 409", () => { + // Regression for secret-mars's PR #738 finding (comment 4418003085). + // The previous behaviour returned 200 with a byte-identical body on + // re-submit, leaving callers no way to tell "I wrote this" from + // "someone wrote this earlier." Re-submits now return 409 with the + // existing row in the body so callers can reconcile. + const ROW = { + txid: TXID, + sender: "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", + contract_id: "SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M.stableswap-stx-ststx-v-1-2", + function_name: "swap-x-for-y", + token_in: "stx", + amount_in: 1000000, + token_out: "ststx", + amount_out: 859839, + burn_block_time: 1762547890, + tx_status: "success", + source: "cron" as const, + scored_value: null, + scored_at: null, + }; + + it("returns 409 + existing_row when inserted:false (row already in D1)", async () => { + mockEnv(); + (verifyAndPersistSwap as Mock).mockResolvedValue({ + status: "verified", + inserted: false, + row: ROW, + }); const res = await POST(buildRequest({ txid: TXID })); - expect(res.status).toBe(202); - expect((kv.put as Mock).mock.calls[0][0]).toBe(`comp:pending:${TXID}`); - expect((kv.put as Mock).mock.calls[0][2]).toEqual({ expirationTtl: 30 * 60 }); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body).toMatchObject({ + code: "txid_already_verified", + retryable: false, + }); + expect(body.existing_row).toEqual(ROW); }); - it("clears the pending KV marker on a verified result (observability hygiene)", async () => { - const kv = makeKv(); - mockEnv({ kv }); + it("includes the row.source in existing_row so callers know which ingestion path won", async () => { + mockEnv(); (verifyAndPersistSwap as Mock).mockResolvedValue({ status: "verified", - inserted: true, - row: { - txid: TXID, - sender: "SP4DXVEC16FS6QR7RBKGWZYJKTXPC81W49W0ATJE", - contract_id: "x", - function_name: "y", - token_in: "stx", - amount_in: 1, - token_out: "stx", - amount_out: 1, - burn_block_time: 1, - tx_status: "success", - source: "agent", - scored_value: null, - scored_at: null, - }, + inserted: false, + row: ROW, }); const res = await POST(buildRequest({ txid: TXID })); - expect(res.status).toBe(200); - expect(kv.delete).toHaveBeenCalledWith(`comp:pending:${TXID}`); + const body = await res.json(); + // ROW.source === "cron" — caller can see cron wrote the row first, + // not agent-submit. Useful diagnostic. + expect(body.existing_row.source).toBe("cron"); }); - it("does NOT read the pending key from the request path (no short-circuit)", async () => { - const kv = makeKv({ get: vi.fn().mockResolvedValue("1") }); - mockEnv({ kv }); - (verifyAndPersistSwap as Mock).mockResolvedValue({ status: "pending" }); - await POST(buildRequest({ txid: TXID })); - // The route must not gate on the cached marker. We tolerate other KV - // reads from unrelated code paths (none exist today) but a positive - // hit here would have indicated the bug. - const getCalls = (kv.get as Mock).mock.calls; - expect(getCalls.length).toBe(0); + it("4-stage lifecycle: pending → pending → verified (200) → re-submit (409)", async () => { + mockEnv(); + const freshRow = { ...ROW, source: "agent" as const }; + (verifyAndPersistSwap as Mock) + .mockResolvedValueOnce({ status: "pending" }) + .mockResolvedValueOnce({ status: "pending" }) + .mockResolvedValueOnce({ status: "verified", inserted: true, row: freshRow }) + .mockResolvedValueOnce({ status: "verified", inserted: false, row: freshRow }); + + const res1 = await POST(buildRequest({ txid: TXID })); + expect(res1.status).toBe(202); + + const res2 = await POST(buildRequest({ txid: TXID })); + expect(res2.status).toBe(202); + + const res3 = await POST(buildRequest({ txid: TXID })); + expect(res3.status).toBe(200); + const body3 = await res3.json(); + expect(body3).toEqual(freshRow); + // 200 body is the row alone — no error / existing_row fields. + expect(body3.error).toBeUndefined(); + expect(body3.existing_row).toBeUndefined(); + + const res4 = await POST(buildRequest({ txid: TXID })); + expect(res4.status).toBe(409); + const body4 = await res4.json(); + expect(body4.code).toBe("txid_already_verified"); + expect(body4.existing_row).toEqual(freshRow); + + // Verifier was invoked exactly 4 times — no request-path short-circuit + // skipped any call. + expect(verifyAndPersistSwap).toHaveBeenCalledTimes(4); }); }); From 8ca8727cca4cef592bf36eee9b468aab6bdcbefe Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 12:17:09 +0545 Subject: [PATCH 49/56] docs(openapi): document 409 txid_already_verified + simplified 202 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI schema for POST /api/competition/trades updated to match the new route behaviour: - 200 — first-time verified write (was 'verified or idempotent') - 202 — pending fallback for Hiro propagation race (was 'pending, KV-tracked, repeats short-circuit') - 409 — NEW: txid_already_verified. Schema pins the response shape (error, code enum: ['txid_already_verified'], retryable: false, existing_row). MCP consumers (aibtcdev/aibtc-mcp-server#510) codegen from this spec, so the typed schema saves them a hand- written branch. Description now references the 'two active ingestion paths (agent / cron)' since the chainhook receiver was scope-cut from this PR but the source enum value remains in migration 005. Refs PR #738 review by @secret-mars Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/openapi.json/route.ts | 36 ++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/app/api/openapi.json/route.ts b/app/api/openapi.json/route.ts index 46f945f6..b17e1fd5 100644 --- a/app/api/openapi.json/route.ts +++ b/app/api/openapi.json/route.ts @@ -1228,14 +1228,14 @@ export function GET() { }, post: { operationId: "submitCompetitionTrade", - summary: "Submit a swap txid for verification", + summary: "Submit a confirmed swap txid for verification", description: - "Agent-submit fast path. The server fetches the tx from Hiro, runs sender + " + - "allowlist checks, parses the FT/STX transfer events, and persists via " + - "INSERT OR IGNORE on (txid). First writer wins across the three ingestion paths " + - "(agent / cron / chainhook). Pending txs return 202 and are tracked in KV " + - "(comp:pending:{txid}, 30-min TTL) — no row is written for pending. Rate limit: " + - "20/min per IP (RATE_LIMIT_MUTATING).", + "Agent-submit fast path. Callers (typically the AIBTC MCP server) pre-check tx " + + "confirmation before submitting; the route checks D1 first (cheap idempotency gate), " + + "fetches the tx from Hiro, runs sender + allowlist checks, parses the FT/STX transfer " + + "events, and persists via INSERT OR IGNORE on (txid). First writer wins across the two " + + "active ingestion paths (agent / cron); re-submits of an already-recorded txid return 409 " + + "with the existing row. Rate limit: 20/min per IP (RATE_LIMIT_MUTATING).", requestBody: { required: true, content: { @@ -1256,12 +1256,12 @@ export function GET() { }, responses: { "200": { - description: "Verified — body is the persisted SwapRow", + description: "First-time verified — body is the persisted SwapRow", content: { "application/json": { schema: { type: "object" } } }, }, "202": { description: - "Pending — tx not yet confirmed. Body is `{ accepted: true }`. Re-poll later; the pending state is tracked in KV (30-min TTL) so repeats short-circuit without hitting Hiro.", + "Pending fallback (rare). Hiro has not yet propagated this tx as terminal. Body is `{ accepted: true, note }`. Retry in a few seconds.", content: { "application/json": { schema: { type: "object" } } }, }, "400": { @@ -1272,6 +1272,24 @@ export function GET() { description: "Hiro could not find the txid", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }, }, + "409": { + description: + "Transaction already verified — this txid is already in the swaps table. Body: `{ error, code: 'txid_already_verified', retryable: false, existing_row }`. The `existing_row.source` identifies which ingestion path wrote first.", + content: { + "application/json": { + schema: { + type: "object", + required: ["error", "code", "retryable", "existing_row"], + properties: { + error: { type: "string" }, + code: { type: "string", enum: ["txid_already_verified"] }, + retryable: { type: "boolean", enum: [false] }, + existing_row: { type: "object" }, + }, + }, + }, + }, + }, "422": { description: "Sender not in registered_wallets, contract+function off allowlist, or terminal failure status / parse failure. Body includes `{ error, code, retryable: false }`.", From a58516756273e4afc3fd47f68a85728b773e3180 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 12:17:18 +0545 Subject: [PATCH 50/56] docs(llms-full): document 409 txid_already_verified + MCP pre-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the 'Submit Trade' section to reflect the new contract: - 200 is now strictly first-time writes (no longer 'newly verified OR idempotent'). - 409 added with the structured body shape and the source-of-existing-row framing so callers know to look at existing_row.source. - 202 description retuned for the post-MCP-pre-check world: it's now a rare propagation-race fallback, not a regular pending-state response. - KV pending tracker no longer mentioned (removed from the route). - Added a closing line about D1-before-Hiro check order — re-submits resolve in a single D1 read, no wasted Hiro quota. Refs PR #738 review by @secret-mars Co-Authored-By: Claude Opus 4.7 (1M context) --- app/llms-full.txt/route.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/app/llms-full.txt/route.ts b/app/llms-full.txt/route.ts index fe599bd7..54c15644 100644 --- a/app/llms-full.txt/route.ts +++ b/app/llms-full.txt/route.ts @@ -942,6 +942,9 @@ Phase 3.2 scoring (separate sub-issue) and remain \`null\` for unscored rows. ### Submit Trade +Pre-check that the tx is terminal (the AIBTC MCP server handles this for its +callers) before submitting: + \`\`\`bash curl -X POST https://aibtc.com/api/competition/trades \\ -H "Content-Type: application/json" \\ @@ -950,11 +953,15 @@ curl -X POST https://aibtc.com/api/competition/trades \\ Response matrix: -- \`200\` — newly verified or idempotent re-submission (first writer wins on \`(txid)\`). - Body is the persisted SwapRow. -- \`202 { accepted: true }\` — tx is pending; re-poll. Pending state is tracked in - KV (\`comp:pending:{txid}\`, 30-min TTL), NOT in D1. Repeat polls short-circuit - without re-hitting Hiro until the TTL expires or the tx settles. +- \`200\` — **first-time** verified write. Body is the persisted SwapRow. +- \`409\` — **already verified**: this txid is already in the swaps table. + Body: \`{ error, code: "txid_already_verified", retryable: false, existing_row }\`. + The \`existing_row.source\` identifies which ingestion path wrote first + (\`agent\` if you / another agent-submit got there first; \`cron\` if the 15-min + catch-up beat you). retryable:false — re-POSTing will keep landing here. +- \`202 { accepted: true, note }\` — fallback for the racy edge case where the + caller saw the tx as confirmed but Hiro hasn't propagated it as terminal yet. + Should be rare; retry in a few seconds. - \`422\` — sender not in registered_wallets, or contract+function not on the allowlist, or tx failed terminally / parse failed. Body: \`{ error, code, retryable: false }\`. - \`404\` — Hiro could not find the txid. @@ -962,6 +969,10 @@ Response matrix: - \`502\` — Hiro upstream error; retryable. - \`503\` — D1 temporarily unavailable; retry per Retry-After header. +The route checks D1 before hitting Hiro — re-submits of an already-verified +txid resolve to a 409 in a single D1 read (no upstream call, no wasted Hiro +quota). + ### Cron Catch-Up (operator-only) \`POST /api/competition/cron\` runs the 15-min catch-up sweep. Walks From 06b0a70f9ef9b3d91a4c0832ec936bfefe3b4e1c Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 12:40:03 +0545 Subject: [PATCH 51/56] fix(competition): success-only gate per @whoabuddy's comp spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verifier was accepting all 8 terminal tx_status values from the swaps schema (success + 7 abort/dropped variants), with a no-op `if (tx.tx_status !== "success") { /* fall through */ }` block that documented an intent but didn't enforce it. Result: failed swaps were getting persisted to D1 with whatever transfer events they had managed to emit before reverting, polluting trade_count and crediting the agent for amount_out the pool never actually delivered. @whoabuddy's reframing on the attribution gist makes the rule explicit: "assert tx_status == success" at the verifier. Only success counts for the comp. Failed terminals (abort_by_response, abort_by_post_condition, dropped_replace_by_fee, dropped_replace_ across_fork, dropped_too_expensive, dropped_stale_garbage_collect, dropped_problematic) → reject with code tx_failed, no row written. Gate placement: success check runs after pending / terminal-status classification but BEFORE sender, allowlist, parse, and persist. So a failed tx never touches D1 at all — cheap fail-fast on Hiro status, no wasted DB work. Schema impact: migration 005's 8-status CHECK constraint stays. The schema still ALLOWS recording failed attempts; the verifier just doesn't write any. If we ever want to opt in to recording failed attempts (UX surface like "user tried, failed"), we'd change the gate without a migration. Route impact: the `tx_failed` rejection code already maps to 422 in the route's switch case — no route change needed. Refs PR #738 + whoabuddy's gist comment 6140059 Refs #734 #652 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/verify.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/competition/verify.ts b/lib/competition/verify.ts index f68a0562..f64d3ad5 100644 --- a/lib/competition/verify.ts +++ b/lib/competition/verify.ts @@ -265,6 +265,21 @@ export async function verifyAndPersistSwap( }; } + // Success-only gate per the comp spec (whoabuddy's reframing on the + // attribution gist: "assert tx_status == success"). Non-success terminal + // statuses — abort_by_response, abort_by_post_condition, dropped_* — are + // schema-allowed in `swaps` for future-proofing but do NOT count toward + // the competition. Reject before doing any further work (parse, FK check, + // DB write); the schema's 8-status CHECK constraint stays so we can opt + // in to recording failed attempts later without a migration. + if (tx.tx_status !== "success") { + return { + status: "rejected", + code: "tx_failed", + reason: `Transaction reached terminal status '${tx.tx_status}' (not success). Failed swaps do not count toward the competition.`, + }; + } + // Sender + allowlist gates. The allowlist check depends on a parsed // contract_call — fail fast if the tx isn't a contract call at all. if (tx.tx_type !== "contract_call" || !tx.contract_call) { @@ -302,17 +317,6 @@ export async function verifyAndPersistSwap( }; } - // A non-success terminal status (abort_by_*, dropped_*) is allowlisted by - // the swaps schema but should be reported back as a soft reject — the row - // still gets persisted (it's a real attempted trade) so the comp surface - // can show "user tried; failed", but the API caller should know they did - // not get credit. We mirror the handoff's tx_failed reason for this. - if (tx.tx_status !== "success") { - // Even on terminal failure, the parser may not have a meaningful event - // pair. Fall through to the parse step — if events are present we'll - // persist them; if not we'll classify the row as malformed and 4xx out. - } - const parseRes = parseSwapFromTx(tx); if (!parseRes.ok) { return { From a10f8c430c239e8917a847b3c507760198c128b0 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 12:40:14 +0545 Subject: [PATCH 52/56] test(competition): success-only gate regression coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the verifier gate. Eight new test cases (it.each over the 7 non-success terminal statuses + a fail-fast ordering assertion): - For each of abort_by_response, abort_by_post_condition, dropped_replace_by_fee, dropped_replace_across_fork, dropped_too_expensive, dropped_stale_garbage_collect, and dropped_problematic: assert verifier returns { rejected, code: tx_failed }, reason includes the status name, and NO row is persisted. - "rejects BEFORE sender/allowlist checks (cheap fail-fast)" — even with an unregistered sender + off-allowlist contract, a failed tx_status short-circuits to tx_failed first. Locks the gate ordering so future refactors can't accidentally do expensive DB work for a tx that's going to fail anyway. Refs PR #738 + whoabuddy's gist comment 6140059 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/competition/__tests__/verify.test.ts | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/lib/competition/__tests__/verify.test.ts b/lib/competition/__tests__/verify.test.ts index 50ce7557..b22e07b1 100644 --- a/lib/competition/__tests__/verify.test.ts +++ b/lib/competition/__tests__/verify.test.ts @@ -178,6 +178,55 @@ describe("verifyAndPersistSwap — pending tx", () => { }); }); +describe("verifyAndPersistSwap — success-only gate (whoabuddy's spec)", () => { + // Migration 005 allows 8 terminal tx_status values in `swaps`. The comp + // only counts `success`; non-success terminals (abort_by_*, dropped_*) + // are rejected with `tx_failed` BEFORE we hit the sender / allowlist / + // parse stages, so no row is written for failed swaps. + it.each([ + ["abort_by_response"], + ["abort_by_post_condition"], + ["dropped_replace_by_fee"], + ["dropped_replace_across_fork"], + ["dropped_too_expensive"], + ["dropped_stale_garbage_collect"], + ["dropped_problematic"], + ])("rejects tx_status=%s with code 'tx_failed' (no row written)", async (status) => { + const failedTx = { ...buildHappyTx(), tx_status: status }; + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(failedTx)); + const db = buildD1Mock({ registered: true, insertChanges: 1 }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("tx_failed"); + expect(result.reason).toContain(status); + }); + + it("rejects BEFORE sender/allowlist checks (cheap fail-fast)", async () => { + // Even with an unregistered sender + off-allowlist contract, a failed + // tx_status should short-circuit to tx_failed first — proves the gate + // runs before downstream DB work. + const failedTx = { + ...buildHappyTx(), + tx_status: "abort_by_post_condition", + sender_address: "SP000000000000000000", + contract_call: { + contract_id: "SP00000.not-on-allowlist", + function_name: "swap-x", + function_args: [], + }, + }; + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(failedTx)); + const db = buildD1Mock({ registered: false }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("tx_failed"); + // NOT sender_not_registered or contract_not_allowlisted — tx_failed + // wins the race. + }); +}); + describe("verifyAndPersistSwap — sender + allowlist gates", () => { it("rejects with sender_not_registered when sender is missing from registered_wallets", async () => { (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(buildHappyTx())); From 48e340dfe075fe91169a527fef377481322addff Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Mon, 11 May 2026 15:08:28 +0545 Subject: [PATCH 53/56] =?UTF-8?q?feat(competition):=20GET=20/api/competiti?= =?UTF-8?q?on/allowlist=20=E2=80=94=20discoverable=20verifier=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public read endpoint that returns the (contract_id, function_name) tuples the verifier accepts at POST /api/competition/trades. Agents can hit this to learn what's in scope before submitting txids; swaps against anything else are rejected with `contract_not_allowlisted`. Response shape: - entries[] — array of {contract_id, functions[]} - total_contracts / total_functions / protocols.bitflow — sizing - provider_address — AIBTC attribution string (audit signal, NOT a gate) - self-doc on ?docs=1 with relatedEndpoints + notes Aggressive edge cache (s-maxage=86400, swr=86400) because the allowlist is a static module-level export — changes only ship via code review, so the cache lifetime can match the deploy cadence. ALEX + Zest stay out per the existing PHASE-3.1-HANDOFF.md scope; notes in the self-doc point to the right place to request additions. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/competition/allowlist/route.ts | 90 ++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 app/api/competition/allowlist/route.ts diff --git a/app/api/competition/allowlist/route.ts b/app/api/competition/allowlist/route.ts new file mode 100644 index 00000000..59ba9485 --- /dev/null +++ b/app/api/competition/allowlist/route.ts @@ -0,0 +1,90 @@ +// CACHE_INVARIANTS:POSTURE=public-only-get +// Public read of the trading-comp (contract_id, function_name) allowlist. +// No auth, no per-caller branching. Edge-cacheable because the allowlist +// is a static export — changes ship via code review, not runtime config. + +import { NextRequest, NextResponse } from "next/server"; +import { + AIBTC_PROVIDER_ADDRESS, + BITFLOW_ALLOWLIST, + COMPETITION_ALLOWLIST, +} from "@/lib/competition/allowlist"; + +function selfDocResponse() { + return NextResponse.json( + { + endpoint: "/api/competition/allowlist", + method: "GET", + description: + "Returns the set of (contract_id, function_name) tuples the trading-comp verifier will accept. Swaps against any other contract/function are rejected with `contract_not_allowlisted` at POST /api/competition/trades. Use this endpoint to discover what's currently in scope before submitting txids.", + queryParameters: { + docs: { + type: "string", + description: "Pass ?docs=1 to return this documentation payload instead of data", + example: "?docs=1", + }, + }, + responseFormat: { + entries: [ + { + contract_id: "string (full Stacks contract id, e.g. SP….contract-name)", + functions: ["string[] (allowed clarity function names on the contract)"], + }, + ], + total_contracts: "number (count of entries — distinct contract_ids)", + total_functions: "number (sum of allowed function names across all entries)", + provider_address: + "string (AIBTC provider address — Bitflow attribution audit signal, NOT a gate; the only authoritative check is the (contract, function) tuple)", + protocols: { + bitflow: "number (count of entries scoped to Bitflow protocol)", + }, + }, + relatedEndpoints: { + submit: "POST /api/competition/trades — verify a swap by txid; rejects with `contract_not_allowlisted` if the swap's contract/function isn't here", + status: "GET /api/competition/status?address={stx} — per-agent verified-swap counts", + trades: "GET /api/competition/trades?address={stx} — per-agent paginated trade history", + }, + notes: [ + "ALEX + Zest are tracked separately and not yet in scope.", + "Entries are reviewed on each PR — there is no runtime mutation surface. To request a new contract/function be added, file an issue against aibtcdev/landing-page.", + "The `provider_address` is the AIBTC attribution string Bitflow's `provider` clarity arg can carry (~6 of ~12 Bitflow contracts inject it). It's recorded for audit but doesn't affect whether a swap is accepted.", + ], + }, + { + headers: { + // Allowlist changes ship via code review, so cache aggressively at the edge. + // 1h browser, 24h shared cache, plus stale-while-revalidate. + "Cache-Control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", + }, + } + ); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + if (searchParams.get("docs") === "1") return selfDocResponse(); + + const totalFunctions = COMPETITION_ALLOWLIST.reduce( + (sum, entry) => sum + entry.functions.length, + 0 + ); + + return NextResponse.json( + { + entries: COMPETITION_ALLOWLIST, + total_contracts: COMPETITION_ALLOWLIST.length, + total_functions: totalFunctions, + provider_address: AIBTC_PROVIDER_ADDRESS, + protocols: { + bitflow: BITFLOW_ALLOWLIST.length, + }, + }, + { + headers: { + // Allowlist changes ship via code review, so cache aggressively at the edge. + // 1h browser, 24h shared cache, plus stale-while-revalidate. + "Cache-Control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", + }, + } + ); +} From 0f3a94fc4893b6cbf2ac4cc4c3d41f1b45aef1db Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 08:30:51 +0545 Subject: [PATCH 54/56] =?UTF-8?q?feat(competition):=20comp-start=20gate=20?= =?UTF-8?q?=E2=80=94=20reject=20trades=20before=202026-05-13T00:00:00Z?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard correctness gate in `verifyAndPersistSwap`. Trades whose `burn_block_time` predates `COMP_START_TIMESTAMP` are rejected with the new code `before_comp_start`, regardless of other validity. Applies to both ingestion paths (agent-submit POST and cron catch-up) since both flow through this function — pre-campaign history can't slip into `swaps` via either route. Placement: after the success-only `tx_failed` gate, before the sender/allowlist/parse stages. Cheap fail-fast — no DB or upstream work for a pre-start tx. Storage: hardcoded constant in `lib/competition/constants.ts`. Campaign start is a one-time committed decision, not a runtime variable; promote to an env var only if preview-vs-prod ever needs to diverge. Unit: `burn_block_time` (Unix epoch seconds). Matches every other comp surface (leaderboard, /trades, status). Anchor lag (~few minutes) is well inside tolerance for a multi-day campaign. Route: `app/api/competition/trades/route.ts` maps the new code to HTTP 422 alongside `tx_failed` / `sender_not_registered` / etc. — same family, `retryable: false`. Tests: 4 new cases in `verify.test.ts` — pre-start reject, boundary (exactly at start = accepted), tx_failed-wins-when-also-pre-start (proves ordering), pre-start beats sender/allowlist downstream gates. Existing happy-path fixture bumped to `COMP_START_TIMESTAMP + 1 day` so the rest of the suite stays above the gate by default. Route + cron tests mock `verifyAndPersistSwap` so they're unaffected. 103/103 competition tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/competition/trades/route.ts | 1 + lib/competition/__tests__/verify.test.ts | 68 +++++++++++++++++++++++- lib/competition/constants.ts | 16 ++++++ lib/competition/verify.ts | 15 ++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 lib/competition/constants.ts diff --git a/app/api/competition/trades/route.ts b/app/api/competition/trades/route.ts index 0a32f8eb..12bd5843 100644 --- a/app/api/competition/trades/route.ts +++ b/app/api/competition/trades/route.ts @@ -332,6 +332,7 @@ export async function POST(request: NextRequest) { case "sender_not_registered": case "contract_not_allowlisted": case "tx_failed": + case "before_comp_start": case "invalid_amount": case "incomplete_events": case "malformed_tx": diff --git a/lib/competition/__tests__/verify.test.ts b/lib/competition/__tests__/verify.test.ts index b22e07b1..152d3409 100644 --- a/lib/competition/__tests__/verify.test.ts +++ b/lib/competition/__tests__/verify.test.ts @@ -26,6 +26,7 @@ vi.mock("@/lib/stacks-api-fetch", () => ({ })); import { verifyAndPersistSwap } from "../verify"; +import { COMP_START_TIMESTAMP } from "../constants"; import { stacksApiFetch } from "@/lib/stacks-api-fetch"; import type { Mock } from "vitest"; @@ -36,6 +37,11 @@ const POOL = "SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M"; const STX_ASSET = "stx"; const STSTX = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token::ststx"; +// Post-comp-start fixture value used by the happy-path tx. The comp-start +// gate (see `verifyAndPersistSwap — comp-start gate`) covers the pre-start +// case explicitly; every other test wants to be past the gate by default. +const POST_START_BURN_TIME = COMP_START_TIMESTAMP + 86400; // start + 1 day + function mockHiroResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -49,7 +55,7 @@ function buildHappyTx() { tx_status: "success", sender_address: AGENT, tx_type: "contract_call", - burn_block_time: 1762547890, + burn_block_time: POST_START_BURN_TIME, contract_call: { contract_id: `${POOL}.stableswap-stx-ststx-v-1-2`, function_name: "swap-x-for-y", @@ -227,6 +233,66 @@ describe("verifyAndPersistSwap — success-only gate (whoabuddy's spec)", () => }); }); +describe("verifyAndPersistSwap — comp-start gate", () => { + it("rejects a tx whose burn_block_time predates COMP_START_TIMESTAMP", async () => { + const preStartTx = { ...buildHappyTx(), burn_block_time: COMP_START_TIMESTAMP - 1 }; + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(preStartTx)); + const db = buildD1Mock({ registered: true, insertChanges: 1 }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("before_comp_start"); + expect(result.reason).toContain(String(COMP_START_TIMESTAMP)); + }); + + it("accepts a tx whose burn_block_time equals COMP_START_TIMESTAMP exactly (boundary)", async () => { + const boundaryTx = { ...buildHappyTx(), burn_block_time: COMP_START_TIMESTAMP }; + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(boundaryTx)); + const db = buildD1Mock({ registered: true, insertChanges: 1 }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("verified"); + if (result.status !== "verified") return; + expect(result.row.burn_block_time).toBe(COMP_START_TIMESTAMP); + }); + + it("lets tx_failed win when a pre-start tx is also failed (cheap fail-fast)", async () => { + // Pre-start AND non-success terminal: tx_failed gate runs first, so + // before_comp_start should NOT be the rejection code. Proves ordering. + const preStartFailed = { + ...buildHappyTx(), + burn_block_time: COMP_START_TIMESTAMP - 1, + tx_status: "abort_by_post_condition", + }; + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(preStartFailed)); + const db = buildD1Mock({ registered: true }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("tx_failed"); + }); + + it("rejects pre-start BEFORE sender/allowlist checks", async () => { + // Pre-start + unregistered sender + off-allowlist contract: before_comp_start + // should win the race, proving the gate runs before downstream DB work. + const preStartUnregistered = { + ...buildHappyTx(), + burn_block_time: COMP_START_TIMESTAMP - 1, + sender_address: "SP000000000000000000", + contract_call: { + contract_id: "SP00000.not-on-allowlist", + function_name: "swap-x", + function_args: [], + }, + }; + (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(preStartUnregistered)); + const db = buildD1Mock({ registered: false }); + const result = await verifyAndPersistSwap({}, db, TXID, "agent"); + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") return; + expect(result.code).toBe("before_comp_start"); + }); +}); + describe("verifyAndPersistSwap — sender + allowlist gates", () => { it("rejects with sender_not_registered when sender is missing from registered_wallets", async () => { (stacksApiFetch as Mock).mockResolvedValue(mockHiroResponse(buildHappyTx())); diff --git a/lib/competition/constants.ts b/lib/competition/constants.ts new file mode 100644 index 00000000..e9ff147b --- /dev/null +++ b/lib/competition/constants.ts @@ -0,0 +1,16 @@ +/** + * Competition campaign start (Unix epoch seconds, UTC). + * + * Trades with `burn_block_time < COMP_START_TIMESTAMP` are rejected by the + * verifier (code: `before_comp_start`) even if otherwise valid — they + * pre-date the campaign window. This is a hard correctness gate so that + * neither the agent-submit fast path nor the cron's catch-up pass can + * pollute `swaps` with pre-campaign history. + * + * 1778630400 = 2026-05-13T00:00:00Z. + * + * To shift the start, update this constant and re-deploy. If we ever need + * separate preview vs prod values, promote to an env-var read from the + * worker bindings. + */ +export const COMP_START_TIMESTAMP = 1778630400; diff --git a/lib/competition/verify.ts b/lib/competition/verify.ts index f64d3ad5..797ff659 100644 --- a/lib/competition/verify.ts +++ b/lib/competition/verify.ts @@ -25,6 +25,7 @@ import { stacksApiFetch } from "@/lib/stacks-api-fetch"; import type { Logger } from "@/lib/logging"; import { STACKS_API_BASE } from "@/lib/identity/constants"; import { isAllowedSwap } from "./allowlist"; +import { COMP_START_TIMESTAMP } from "./constants"; import { parseSwapFromTx, type HiroTxForSwap } from "./parse"; import type { SwapRow } from "./d1-reads"; @@ -49,6 +50,7 @@ export type VerifyFailureCode = | "tx_not_found" | "tx_fetch_failed" | "tx_failed" + | "before_comp_start" | "malformed_tx" | "invalid_amount" | "incomplete_events" @@ -280,6 +282,19 @@ export async function verifyAndPersistSwap( }; } + // Comp-start gate. Trades whose burn_block_time predates the campaign + // window do not count, regardless of other validity. Reject before the + // sender / allowlist / parse stages so the cron's catch-up pass can't + // backfill pre-campaign history into `swaps` either. + const txBurnTime = tx.burn_block_time ?? 0; + if (txBurnTime < COMP_START_TIMESTAMP) { + return { + status: "rejected", + code: "before_comp_start", + reason: `Trade burn_block_time ${txBurnTime} is before competition start ${COMP_START_TIMESTAMP}.`, + }; + } + // Sender + allowlist gates. The allowlist check depends on a parsed // contract_call — fail fast if the tx isn't a contract call at all. if (tx.tx_type !== "contract_call" || !tx.contract_call) { From 85199a85d04ab36e5399fccf06ce2f49c9eafc5d Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Tue, 12 May 2026 09:43:39 +0545 Subject: [PATCH 55/56] refactor(competition): move cron cursor from KV to D1 (competition_state) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the KV `comp:cron:cursor` key (under VERIFIED_AGENTS) with a row in the new `competition_state` table from migration 009. Per @whoabuddy's #738 review note that the verifier follow-up needs proper cursor state (https://github.com/aibtcdev/landing-page/pull/738#issuecomment-4426307229): durable, queryable, and lives in the same store as the data it gates. The scheduler primitive (Cron Trigger vs DO alarm) becomes orthogonal now that state isn't KV-bound — either fires the same D1-backed handler. Issue #765 (DO-alarm follow-up) closed: that decision is deferred and no longer blocks anything since the state question is resolved here. What changed: - migrations/009_competition_state.sql — generic (key, value, updated_at) table; absorbs future cron state without per-signal migrations - lib/competition/state.ts — getCronCursor / setCronCursor / clearCronCursor with SQLite UPSERT and unixepoch() default - lib/competition/cron.ts — drops VERIFIED_AGENTS from env, drops the exported CRON_CURSOR_KV_KEY constant and the cursorKey option (no longer needed since state.ts owns the key) - app/api/competition/cron/route.ts — drops KV from the env arg passed to runCompetitionCron; self-doc updated to reference D1 - lib/competition/__tests__/cron.test.ts — D1 cursor mock with a tiny inline store, exposes cursorOps.{set,clear} for assertion - app/api/competition/__tests__/cron-route.test.ts — drop unused KV binding from mockEnv D1 patterns verified idiomatic against Cloudflare docs: - INSERT ... ON CONFLICT(key) DO UPDATE SET ... = excluded.value (UPSERT) - unixepoch() in DEFAULT clause - Numbered placeholders (?1, ?2) — Cloudflare's recommended style 103/103 competition tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../competition/__tests__/cron-route.test.ts | 1 - app/api/competition/cron/route.ts | 4 +- lib/competition/__tests__/cron.test.ts | 137 +++++++++++------- lib/competition/cron.ts | 23 ++- lib/competition/state.ts | 42 ++++++ migrations/009_competition_state.sql | 16 ++ 6 files changed, 157 insertions(+), 66 deletions(-) create mode 100644 lib/competition/state.ts create mode 100644 migrations/009_competition_state.sql diff --git a/app/api/competition/__tests__/cron-route.test.ts b/app/api/competition/__tests__/cron-route.test.ts index fd75e0a4..e2a44bef 100644 --- a/app/api/competition/__tests__/cron-route.test.ts +++ b/app/api/competition/__tests__/cron-route.test.ts @@ -31,7 +31,6 @@ function mockEnv(opts: { omitDb?: boolean; omitSecret?: boolean } = {}) { (getCloudflareContext as Mock).mockReturnValue({ env: { DB: db, - VERIFIED_AGENTS: { get: vi.fn(), put: vi.fn(), delete: vi.fn() } as unknown as KVNamespace, HIRO_API_KEY: undefined, LOGS: undefined, ...(opts.omitSecret ? {} : { CRON_SECRET: SECRET }), diff --git a/app/api/competition/cron/route.ts b/app/api/competition/cron/route.ts index b422bf9f..f2858eca 100644 --- a/app/api/competition/cron/route.ts +++ b/app/api/competition/cron/route.ts @@ -29,7 +29,7 @@ export async function GET() { }, notes: [ "Per-run cap: 100 addresses (CRON_MAX_ADDRESSES_PER_RUN). Sized for a 15-min cadence — the full membership cycles in roughly 5 runs at the current scale.", - "The sweep resumes across runs via the comp:cron:cursor KV key.", + "The sweep resumes across runs via D1 (competition_state.cron_cursor).", "wrangler cron-trigger wiring is tracked as a follow-up; this route is callable today via HTTPS with the shared secret.", ], }, @@ -75,7 +75,7 @@ export async function POST(request: NextRequest) { } const summary = await runCompetitionCron( - { DB: db, VERIFIED_AGENTS: env.VERIFIED_AGENTS, HIRO_API_KEY: env.HIRO_API_KEY }, + { DB: db, HIRO_API_KEY: env.HIRO_API_KEY }, logger ); diff --git a/lib/competition/__tests__/cron.test.ts b/lib/competition/__tests__/cron.test.ts index fa7fbcdc..f96af145 100644 --- a/lib/competition/__tests__/cron.test.ts +++ b/lib/competition/__tests__/cron.test.ts @@ -22,34 +22,72 @@ const POOL = "SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M"; const ALLOWED_CONTRACT = `${POOL}.stableswap-stx-ststx-v-1-2`; const ALLOWED_FN = "swap-x-for-y"; -function makeKv(initialCursor: string | null = null) { - const store = new Map(); - if (initialCursor) store.set("comp:cron:cursor", initialCursor); - return { - get: vi.fn(async (k: string) => store.get(k) ?? null), - put: vi.fn(async (k: string, v: string) => { - store.set(k, v); - }), - delete: vi.fn(async (k: string) => { - store.delete(k); - }), - _store: store, - } as unknown as KVNamespace & { _store: Map }; -} +/** + * D1 mock that handles both the registered_wallets page query and the + * competition_state cursor get/set/delete operations. Cursor writes are + * captured for assertion via the returned `cursorOps` object. + */ +function makeDb( + rows: string[], + opts: { initialCursor?: string | null } = {} +) { + const cursorStore: { value: string | null } = { + value: opts.initialCursor ?? null, + }; + const cursorOps = { + set: vi.fn<(cursor: string) => void>(), + clear: vi.fn<() => void>(), + }; -function makeDb(rows: string[]) { const prepare = vi.fn((sql: string) => { - const usesCursor = sql.includes("stx_address > ?1"); + const trimmed = sql.trim(); + + // cursor SELECT + if (trimmed.startsWith("SELECT value FROM competition_state")) { + return { + bind: vi.fn().mockReturnValue({ + first: vi.fn().mockResolvedValue( + cursorStore.value !== null ? { value: cursorStore.value } : null + ), + }), + }; + } + // cursor UPSERT + if (trimmed.startsWith("INSERT INTO competition_state")) { + return { + bind: vi.fn((_key: string, value: string) => ({ + run: vi.fn().mockImplementation(() => { + cursorStore.value = value; + cursorOps.set(value); + return Promise.resolve({ meta: { changes: 1 } }); + }), + })), + }; + } + // cursor DELETE + if (trimmed.startsWith("DELETE FROM competition_state")) { + return { + bind: vi.fn().mockReturnValue({ + run: vi.fn().mockImplementation(() => { + cursorStore.value = null; + cursorOps.clear(); + return Promise.resolve({ meta: { changes: 1 } }); + }), + }), + }; + } + // registered_wallets page query return { bind: vi.fn().mockReturnValue({ all: vi.fn().mockResolvedValue({ results: rows.map((stx_address) => ({ stx_address })), }), }), - _usesCursor: usesCursor, }; }); - return { prepare } as unknown as D1Database; + + const db = { prepare } as unknown as D1Database; + return { db, cursorOps }; } beforeEach(() => { @@ -58,8 +96,7 @@ beforeEach(() => { describe("runCompetitionCron — walk + dispatch", () => { it("walks the address page, finds allowlisted txs, and submits them with source='cron'", async () => { - const kv = makeKv(); - const db = makeDb(["SP_ADDR_001"]); + const { db } = makeDb(["SP_ADDR_001"]); (verifyAndPersistSwap as Mock).mockResolvedValue({ status: "verified", inserted: true, @@ -75,7 +112,7 @@ describe("runCompetitionCron — walk + dispatch", () => { ]); const summary = await runCompetitionCron( - { DB: db, VERIFIED_AGENTS: kv, HIRO_API_KEY: undefined }, + { DB: db, HIRO_API_KEY: undefined }, undefined, { fetchAddressTxsImpl } ); @@ -93,8 +130,7 @@ describe("runCompetitionCron — walk + dispatch", () => { }); it("skips off-allowlist contract calls without invoking verify (saves Hiro cost)", async () => { - const kv = makeKv(); - const db = makeDb(["SP_ADDR_001"]); + const { db } = makeDb(["SP_ADDR_001"]); const fetchAddressTxsImpl = vi.fn().mockResolvedValue([ { tx_id: "0xaaa", @@ -104,7 +140,7 @@ describe("runCompetitionCron — walk + dispatch", () => { ]); const summary = await runCompetitionCron( - { DB: db, VERIFIED_AGENTS: kv }, + { DB: db }, undefined, { fetchAddressTxsImpl } ); @@ -114,14 +150,13 @@ describe("runCompetitionCron — walk + dispatch", () => { }); it("skips non-contract_call txs without dispatch", async () => { - const kv = makeKv(); - const db = makeDb(["SP_ADDR_001"]); + const { db } = makeDb(["SP_ADDR_001"]); const fetchAddressTxsImpl = vi.fn().mockResolvedValue([ { tx_id: "0xaaa", tx_type: "token_transfer" }, ]); const summary = await runCompetitionCron( - { DB: db, VERIFIED_AGENTS: kv }, + { DB: db }, undefined, { fetchAddressTxsImpl } ); @@ -130,8 +165,7 @@ describe("runCompetitionCron — walk + dispatch", () => { }); it("tallies inserted vs alreadyKnown vs pending vs rejected", async () => { - const kv = makeKv(); - const db = makeDb(["SP_ADDR_001"]); + const { db } = makeDb(["SP_ADDR_001"]); (verifyAndPersistSwap as Mock) .mockResolvedValueOnce({ status: "verified", inserted: true, row: {} }) .mockResolvedValueOnce({ status: "verified", inserted: false, row: {} }) @@ -146,7 +180,7 @@ describe("runCompetitionCron — walk + dispatch", () => { ]); const summary = await runCompetitionCron( - { DB: db, VERIFIED_AGENTS: kv }, + { DB: db }, undefined, { fetchAddressTxsImpl } ); @@ -164,74 +198,75 @@ describe("runCompetitionCron — walk + dispatch", () => { describe("runCompetitionCron — cursor persistence", () => { it("persists the next cursor when the page is full (more addresses to walk)", async () => { - const kv = makeKv(); const fullPage: string[] = Array.from( { length: CRON_MAX_ADDRESSES_PER_RUN }, (_, i) => `SP_ADDR_${String(i).padStart(3, "0")}` ); - const db = makeDb(fullPage); + const { db, cursorOps } = makeDb(fullPage); const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); const summary = await runCompetitionCron( - { DB: db, VERIFIED_AGENTS: kv }, + { DB: db }, undefined, { fetchAddressTxsImpl } ); expect(summary.cursor).toBe(fullPage[fullPage.length - 1]); - expect(kv.put).toHaveBeenCalledWith("comp:cron:cursor", fullPage[fullPage.length - 1]); + expect(cursorOps.set).toHaveBeenCalledWith(fullPage[fullPage.length - 1]); + expect(cursorOps.clear).not.toHaveBeenCalled(); }); it("deletes the cursor when the page is partial (walk wrapped)", async () => { - const kv = makeKv("SP_PRIOR_CURSOR"); - const db = makeDb(["SP_ADDR_001", "SP_ADDR_002"]); + const { db, cursorOps } = makeDb( + ["SP_ADDR_001", "SP_ADDR_002"], + { initialCursor: "SP_PRIOR_CURSOR" } + ); const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); const summary = await runCompetitionCron( - { DB: db, VERIFIED_AGENTS: kv }, + { DB: db }, undefined, { fetchAddressTxsImpl } ); expect(summary.cursor).toBeNull(); - expect(kv.delete).toHaveBeenCalledWith("comp:cron:cursor"); + expect(cursorOps.clear).toHaveBeenCalled(); + expect(cursorOps.set).not.toHaveBeenCalled(); }); it("uses the cursor query branch when a cursor is present", async () => { - const kv = makeKv("SP_LAST_RUN"); - const db = makeDb([]); + const { db } = makeDb([], { initialCursor: "SP_LAST_RUN" }); const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); await runCompetitionCron( - { DB: db, VERIFIED_AGENTS: kv }, + { DB: db }, undefined, { fetchAddressTxsImpl } ); - const prepareCalls = (db.prepare as Mock).mock.calls; - expect(prepareCalls[0][0]).toContain("stx_address > ?1"); + const prepareCalls = (db.prepare as Mock).mock.calls.map((c) => c[0] as string); + expect(prepareCalls.some((sql) => sql.includes("stx_address > ?1"))).toBe(true); }); it("uses the head-of-list query branch when no cursor is present", async () => { - const kv = makeKv(); - const db = makeDb([]); + const { db } = makeDb([]); const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); await runCompetitionCron( - { DB: db, VERIFIED_AGENTS: kv }, + { DB: db }, undefined, { fetchAddressTxsImpl } ); - const sql = (db.prepare as Mock).mock.calls[0][0] as string; - expect(sql).not.toContain("stx_address > ?"); + const prepareCalls = (db.prepare as Mock).mock.calls.map((c) => c[0] as string); + const pageQueries = prepareCalls.filter((sql) => sql.includes("registered_wallets")); + expect(pageQueries.every((sql) => !sql.includes("stx_address > ?"))).toBe(true); }); }); describe("runCompetitionCron — fault tolerance", () => { it("counts a verify throw as rejected and continues the sweep", async () => { - const kv = makeKv(); - const db = makeDb(["SP_ADDR_001"]); + const { db } = makeDb(["SP_ADDR_001"]); (verifyAndPersistSwap as Mock).mockRejectedValueOnce(new Error("boom")); const fetchAddressTxsImpl = vi.fn().mockResolvedValue([ @@ -239,7 +274,7 @@ describe("runCompetitionCron — fault tolerance", () => { ]); const summary = await runCompetitionCron( - { DB: db, VERIFIED_AGENTS: kv }, + { DB: db }, undefined, { fetchAddressTxsImpl } ); diff --git a/lib/competition/cron.ts b/lib/competition/cron.ts index 66697bc7..b918c55a 100644 --- a/lib/competition/cron.ts +++ b/lib/competition/cron.ts @@ -12,8 +12,10 @@ * - Max 100 addresses per execution. Tuned for a 15-min cadence: at the * current ~430 registered wallets, the full list cycles in roughly * 5 runs (~75 min). Single Hiro client, single rate-limit budget. - * - Resume from KV cursor `comp:cron:cursor` so subsequent runs continue - * where the previous one stopped rather than retrying the head N times. + * - Resume from D1 cursor (`competition_state.cron_cursor`) so subsequent + * runs continue where the previous one stopped. Moved from KV to D1 + * per @whoabuddy's #738 review note that cursor state belongs alongside + * the data it gates. * * Returns a structured summary for the logs: * { scanned, found, inserted, alreadyKnown, rejected, pending, cursor } @@ -29,10 +31,10 @@ import { stacksApiFetch } from "@/lib/stacks-api-fetch"; import { STACKS_API_BASE } from "@/lib/identity/constants"; import { verifyAndPersistSwap } from "./verify"; import { isAllowedSwap } from "./allowlist"; +import { getCronCursor, setCronCursor, clearCronCursor } from "./state"; -/** Per-run cap on addresses scanned. Cron resumes from the KV cursor next run. */ +/** Per-run cap on addresses scanned. Cron resumes from the D1 cursor next run. */ export const CRON_MAX_ADDRESSES_PER_RUN = 100; -export const CRON_CURSOR_KV_KEY = "comp:cron:cursor"; /** Per-address tx history page size. */ const HIRO_TX_PAGE_LIMIT = 25; @@ -112,8 +114,6 @@ async function fetchAddressPage( } export interface RunCronOptions { - /** Override the KV cursor key. Used by tests for isolation. */ - cursorKey?: string; /** Inject a custom address-history fetcher (for tests). */ fetchAddressTxsImpl?: typeof fetchAddressTxs; } @@ -127,14 +127,13 @@ export interface RunCronOptions { * rather than always starting at the head. */ export async function runCompetitionCron( - env: { DB: D1Database; VERIFIED_AGENTS: KVNamespace; HIRO_API_KEY?: string }, + env: { DB: D1Database; HIRO_API_KEY?: string }, logger?: Logger, options: RunCronOptions = {} ): Promise { - const cursorKey = options.cursorKey ?? CRON_CURSOR_KV_KEY; const txsFetcher = options.fetchAddressTxsImpl ?? fetchAddressTxs; - const cursor = (await env.VERIFIED_AGENTS.get(cursorKey)) ?? null; + const cursor = await getCronCursor(env.DB); const { rows, nextCursor } = await fetchAddressPage(env.DB, cursor); const summary: CronSummary = { @@ -177,11 +176,11 @@ export async function runCompetitionCron( } // Persist next cursor. When nextCursor is null (we walked the tail), - // delete the key so the next run starts fresh at the head. + // clear the row so the next run starts fresh at the head. if (nextCursor) { - await env.VERIFIED_AGENTS.put(cursorKey, nextCursor); + await setCronCursor(env.DB, nextCursor); } else { - await env.VERIFIED_AGENTS.delete(cursorKey); + await clearCronCursor(env.DB); } logger?.info?.("competition.cron.summary", { ...summary }); diff --git a/lib/competition/state.ts b/lib/competition/state.ts new file mode 100644 index 00000000..931e5e63 --- /dev/null +++ b/lib/competition/state.ts @@ -0,0 +1,42 @@ +/** + * D1-backed persistent state for the competition cron. + * + * Replaces the KV `comp:cron:cursor` key (formerly under `VERIFIED_AGENTS`) + * with a row in the `competition_state` table from migration 009. Same + * three-op surface (get / set / clear) so the cron only changes which + * driver it imports. + * + * Why D1 instead of KV: see PR #738 review thread + * (https://github.com/aibtcdev/landing-page/pull/738#issuecomment-4426307229). + * Cursor state belongs in the same store as the data it gates so we can + * audit + recover from a single source, and so future scheduler + * primitives (Cron Trigger, DO alarm, whatever) all read the same row. + */ + +const CRON_CURSOR_KEY = "cron_cursor"; + +export async function getCronCursor(db: D1Database): Promise { + const row = await db + .prepare(`SELECT value FROM competition_state WHERE key = ?1`) + .bind(CRON_CURSOR_KEY) + .first<{ value: string }>(); + return row?.value ?? null; +} + +export async function setCronCursor(db: D1Database, cursor: string): Promise { + await db + .prepare( + `INSERT INTO competition_state (key, value, updated_at) + VALUES (?1, ?2, unixepoch()) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at` + ) + .bind(CRON_CURSOR_KEY, cursor) + .run(); +} + +export async function clearCronCursor(db: D1Database): Promise { + await db + .prepare(`DELETE FROM competition_state WHERE key = ?1`) + .bind(CRON_CURSOR_KEY) + .run(); +} diff --git a/migrations/009_competition_state.sql b/migrations/009_competition_state.sql new file mode 100644 index 00000000..c1af9ffb --- /dev/null +++ b/migrations/009_competition_state.sql @@ -0,0 +1,16 @@ +-- Migration 009: competition_state table. +-- Tiny K/V scratchpad for the competition cron's persistent state. +-- Replaces KV `comp:cron:cursor` per @whoabuddy's #738 review note that +-- "we need cursor state" (https://github.com/aibtcdev/landing-page/pull/738#issuecomment-4426307229): +-- queryable, durable, and lives in the same store as everything else the +-- comp surface reads/writes. +-- +-- Schema is intentionally generic — future cron state (last_run_at, +-- consecutive_failures, etc.) reuses the same table rather than spawning +-- one tiny migration per signal. + +CREATE TABLE competition_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); From 9afa89d19703f35e73e240a32e2153dda6a593d7 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Tue, 12 May 2026 16:11:34 -0700 Subject: [PATCH 56/56] refactor(competition): run catch-up via SchedulerDO --- app/.well-known/agent.json/route.ts | 4 +- .../admin/scheduler/__tests__/route.test.ts | 28 ++- app/api/admin/scheduler/route.ts | 4 +- .../competition/__tests__/cron-route.test.ts | 136 --------------- .../__tests__/d1-throws-fallback.test.ts | 8 +- .../__tests__/post-verifier.test.ts | 18 +- app/api/competition/cron/route.ts | 83 --------- app/api/competition/trades/route.ts | 13 +- app/api/openapi.json/route.ts | 53 +----- app/llms-full.txt/route.ts | 25 ++- app/llms.txt/route.ts | 3 +- .../{cron.test.ts => scheduler.test.ts} | 36 ++-- lib/competition/constants.ts | 2 +- lib/competition/d1-reads.ts | 2 +- lib/competition/{cron.ts => scheduler.ts} | 62 +++---- lib/competition/state.ts | 25 +-- lib/competition/verify.ts | 16 +- lib/scheduler/rpc-types.ts | 10 +- migrations/009_competition_state.sql | 6 +- worker.ts | 165 ++++++++++++++---- wrangler.jsonc | 7 +- 21 files changed, 282 insertions(+), 424 deletions(-) delete mode 100644 app/api/competition/__tests__/cron-route.test.ts delete mode 100644 app/api/competition/cron/route.ts rename lib/competition/__tests__/{cron.test.ts => scheduler.test.ts} (90%) rename lib/competition/{cron.ts => scheduler.ts} (73%) diff --git a/app/.well-known/agent.json/route.ts b/app/.well-known/agent.json/route.ts index 766406b4..5ddb88a5 100644 --- a/app/.well-known/agent.json/route.ts +++ b/app/.well-known/agent.json/route.ts @@ -377,8 +377,8 @@ export function GET() { "history with keyset pagination over (burn_block_time, txid). " + "POST /api/competition/trades submits a Stacks txid for verification — server fetches via Hiro, " + "runs allowlist + sender checks, INSERT OR IGNOREs into the swaps table (first writer wins). " + - "Pending txs return 202 and are tracked in KV with a 30-min TTL (no D1 row for pending). " + - "Two ingestion paths today: agent-submit (this POST) and a 15-min catch-up cron. " + + "Pending txs return 202 with no D1 row. " + + "Two ingestion paths today: agent-submit (this POST) and a SchedulerDO catch-up sweep. " + "A third 'chainhook' source value is reserved in the schema for a future real-time stream.", tags: ["competition", "trading", "swaps", "leaderboard"], examples: [ diff --git a/app/api/admin/scheduler/__tests__/route.test.ts b/app/api/admin/scheduler/__tests__/route.test.ts index be92a556..ee235753 100644 --- a/app/api/admin/scheduler/__tests__/route.test.ts +++ b/app/api/admin/scheduler/__tests__/route.test.ts @@ -15,7 +15,10 @@ import { GET, POST } from "../route"; const schedulerStub = { status: vi.fn().mockResolvedValue({ now: 123 }), - refreshNow: vi.fn().mockResolvedValue({ tenero: { succeeded: 1 } }), + refreshNow: vi.fn().mockResolvedValue({ + tenero: { succeeded: 1 }, + competition: { scanned: 1 }, + }), pauseUntil: vi.fn().mockResolvedValue(undefined), resume: vi.fn().mockResolvedValue(undefined), }; @@ -56,7 +59,7 @@ describe("GET /api/admin/scheduler", () => { it("returns scheduler status with no-store/noindex headers", async () => { const response = await GET(request("/api/admin/scheduler")); - const body = await response.json(); + const body = (await response.json()) as any; expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("no-store"); @@ -67,7 +70,7 @@ describe("GET /api/admin/scheduler", () => { it("rejects unknown scheduler names before touching a stub", async () => { const response = await GET(request("/api/admin/scheduler?name=typo")); - const body = await response.json(); + const body = (await response.json()) as any; expect(response.status).toBe(400); expect(body.error).toContain("Unsupported scheduler name"); @@ -80,7 +83,7 @@ describe("POST /api/admin/scheduler", () => { const response = await POST( request("/api/admin/scheduler?action=pause", "POST") ); - const body = await response.json(); + const body = (await response.json()) as any; expect(response.status).toBe(400); expect(body.error).toContain("Missing `until`"); @@ -91,7 +94,7 @@ describe("POST /api/admin/scheduler", () => { const response = await POST( request("/api/admin/scheduler?action=refresh&task=prices", "POST") ); - const body = await response.json(); + const body = (await response.json()) as any; expect(response.status).toBe(400); expect(body.error).toContain("Unsupported task"); @@ -102,7 +105,7 @@ describe("POST /api/admin/scheduler", () => { const response = await POST( request("/api/admin/scheduler?name=v3&action=refresh&task=all", "POST") ); - const body = await response.json(); + const body = (await response.json()) as any; expect(response.status).toBe(200); expect(schedulerNamespace.idFromName).toHaveBeenCalledWith("v3"); @@ -110,7 +113,18 @@ describe("POST /api/admin/scheduler", () => { expect(body).toEqual({ name: "v3", task: "all", - result: { tenero: { succeeded: 1 } }, + result: { tenero: { succeeded: 1 }, competition: { scanned: 1 } }, }); }); + + it("refreshes the competition scheduler task", async () => { + const response = await POST( + request("/api/admin/scheduler?action=refresh&task=competition", "POST") + ); + const body = (await response.json()) as any; + + expect(response.status).toBe(200); + expect(schedulerStub.refreshNow).toHaveBeenCalledWith("competition"); + expect(body.task).toBe("competition"); + }); }); diff --git a/app/api/admin/scheduler/route.ts b/app/api/admin/scheduler/route.ts index 9a197eb2..17e989dc 100644 --- a/app/api/admin/scheduler/route.ts +++ b/app/api/admin/scheduler/route.ts @@ -5,7 +5,7 @@ import type { SchedulerRpc, SchedulerTask } from "@/lib/scheduler/rpc-types"; const DEFAULT_SCHEDULER_INSTANCE = "v2"; const ALLOWED_SCHEDULER_INSTANCES = new Set(["v1", "v2", "v3"]); -const ALLOWED_TASKS = new Set(["tenero", "all"]); +const ALLOWED_TASKS = new Set(["tenero", "competition", "all"]); function json(body: unknown, init: ResponseInit = {}) { const headers = new Headers(init.headers); @@ -33,7 +33,7 @@ function schedulerTask(url: URL): SchedulerTask | NextResponse { const task = url.searchParams.get("task") || "tenero"; if (!ALLOWED_TASKS.has(task as SchedulerTask)) { return json( - { error: "Unsupported task. Use tenero or all." }, + { error: "Unsupported task. Use tenero, competition, or all." }, { status: 400 } ); } diff --git a/app/api/competition/__tests__/cron-route.test.ts b/app/api/competition/__tests__/cron-route.test.ts deleted file mode 100644 index e2a44bef..00000000 --- a/app/api/competition/__tests__/cron-route.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Tests for POST /api/competition/cron — Phase 3.1 PR-D route layer. - * - * Exercises the route's auth gate + dispatch into runCompetitionCron. - * The walk + verifier logic itself is unit-tested in cron.test.ts. - */ - -import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; -import { NextRequest } from "next/server"; - -vi.mock("@opennextjs/cloudflare", () => ({ - getCloudflareContext: vi.fn(), -})); -vi.mock("@/lib/logging", () => ({ - createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), - createConsoleLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), - isLogsRPC: () => false, -})); -vi.mock("@/lib/competition/cron", () => ({ - runCompetitionCron: vi.fn(), -})); - -import { POST, GET } from "../cron/route"; -import { getCloudflareContext } from "@opennextjs/cloudflare"; -import { runCompetitionCron } from "@/lib/competition/cron"; - -const SECRET = "test-cron-secret"; - -function mockEnv(opts: { omitDb?: boolean; omitSecret?: boolean } = {}) { - const db = opts.omitDb ? undefined : ({ prepare: vi.fn() } as unknown as D1Database); - (getCloudflareContext as Mock).mockReturnValue({ - env: { - DB: db, - HIRO_API_KEY: undefined, - LOGS: undefined, - ...(opts.omitSecret ? {} : { CRON_SECRET: SECRET }), - }, - ctx: { waitUntil: vi.fn() }, - }); -} - -function buildRequest(secret?: string): NextRequest { - const headers: Record = { "content-type": "application/json" }; - if (secret !== undefined) headers["x-cron-secret"] = secret; - return new NextRequest("https://aibtc.com/api/competition/cron", { - method: "POST", - headers, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("POST /api/competition/cron — auth", () => { - it("returns 500 when CRON_SECRET is not configured", async () => { - mockEnv({ omitSecret: true }); - const res = await POST(buildRequest(SECRET)); - expect(res.status).toBe(500); - }); - - it("returns 401 when X-Cron-Secret is absent", async () => { - mockEnv(); - const res = await POST(buildRequest(undefined)); - expect(res.status).toBe(401); - }); - - it("returns 401 when X-Cron-Secret does not match", async () => { - mockEnv(); - const res = await POST(buildRequest("wrong-secret")); - expect(res.status).toBe(401); - }); - - it("accepts the request when the secret matches", async () => { - mockEnv(); - (runCompetitionCron as Mock).mockResolvedValue({ - scanned: 0, found: 0, inserted: 0, alreadyKnown: 0, pending: 0, rejected: 0, cursor: null, - }); - const res = await POST(buildRequest(SECRET)); - expect(res.status).toBe(200); - }); -}); - -describe("POST /api/competition/cron — bindings + dispatch", () => { - it("returns 503 + Retry-After when D1 binding is missing", async () => { - mockEnv({ omitDb: true }); - const res = await POST(buildRequest(SECRET)); - expect(res.status).toBe(503); - expect(res.headers.get("Retry-After")).toBe("60"); - }); - - it("returns the cron summary on success", async () => { - mockEnv(); - (runCompetitionCron as Mock).mockResolvedValue({ - scanned: 100, - found: 5, - inserted: 3, - alreadyKnown: 1, - pending: 1, - rejected: 0, - cursor: "SP_NEXT", - }); - const res = await POST(buildRequest(SECRET)); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body).toEqual({ - scanned: 100, - found: 5, - inserted: 3, - alreadyKnown: 1, - pending: 1, - rejected: 0, - cursor: "SP_NEXT", - }); - }); - - it("sets Cache-Control: no-store on every response shape", async () => { - mockEnv(); - (runCompetitionCron as Mock).mockResolvedValue({ - scanned: 0, found: 0, inserted: 0, alreadyKnown: 0, pending: 0, rejected: 0, cursor: null, - }); - const res = await POST(buildRequest(SECRET)); - expect(res.headers.get("Cache-Control")).toBe("no-store"); - }); -}); - -describe("GET /api/competition/cron — self-doc", () => { - it("returns the documentation payload without invoking the cron", async () => { - mockEnv(); - const res = await GET(); - expect(res.status).toBe(200); - expect(runCompetitionCron).not.toHaveBeenCalled(); - const body = await res.json(); - expect(body.endpoint).toBe("/api/competition/cron"); - }); -}); diff --git a/app/api/competition/__tests__/d1-throws-fallback.test.ts b/app/api/competition/__tests__/d1-throws-fallback.test.ts index 4f08f9f3..9e396bb3 100644 --- a/app/api/competition/__tests__/d1-throws-fallback.test.ts +++ b/app/api/competition/__tests__/d1-throws-fallback.test.ts @@ -90,7 +90,7 @@ describe("Phase 3.1 PR-A — D1-throws fallback policy (status)", () => { const res = await statusGet(buildStatusRequest()); expect(res.status).toBe(503); - const body = await res.json(); + const body = (await res.json()) as any; expect(body).toMatchObject({ error: "transient_d1_unavailable", retry_after: 5, @@ -125,7 +125,7 @@ describe("Phase 3.1 PR-A — D1-throws fallback policy (trades)", () => { const res = await tradesGet(buildTradesRequest()); expect(res.status).toBe(503); - const body = await res.json(); + const body = (await res.json()) as any; expect(body).toMatchObject({ error: "transient_d1_unavailable", retry_after: 5, @@ -203,7 +203,7 @@ describe("Phase 3.1 PR-A — self-doc (?docs=1)", () => { ); expect(res.status).toBe(200); expect(getCompetitionStatusFromD1).not.toHaveBeenCalled(); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.endpoint).toBe("/api/competition/status"); }); @@ -213,7 +213,7 @@ describe("Phase 3.1 PR-A — self-doc (?docs=1)", () => { ); expect(res.status).toBe(200); expect(listSwapsFromD1).not.toHaveBeenCalled(); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.endpoint).toBe("/api/competition/trades"); }); }); diff --git a/app/api/competition/__tests__/post-verifier.test.ts b/app/api/competition/__tests__/post-verifier.test.ts index e630dfd7..bdeb1eef 100644 --- a/app/api/competition/__tests__/post-verifier.test.ts +++ b/app/api/competition/__tests__/post-verifier.test.ts @@ -12,7 +12,7 @@ * propagation race only) * - 409 Conflict on idempotent re-submit (the row already exists in D1) * — see secret-mars's PR #738 finding (comment 4418003085) - * - 202 + KV write when verify returns pending + * - no KV writes when verify returns pending * - 200 + row when verify returns verified * - 422 on verifier rejections (sender/allowlist/parse) * - 404 on tx_not_found @@ -137,7 +137,7 @@ describe("POST /api/competition/trades — pending fallback (no KV writes)", () (verifyAndPersistSwap as Mock).mockResolvedValue({ status: "pending" }); const res = await POST(buildRequest({ txid: TXID })); expect(res.status).toBe(202); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.accepted).toBe(true); expect(body.note).toMatch(/propagated/i); }); @@ -184,7 +184,7 @@ describe("POST /api/competition/trades — idempotent re-submit → 409", () => }); const res = await POST(buildRequest({ txid: TXID })); expect(res.status).toBe(409); - const body = await res.json(); + const body = (await res.json()) as any; expect(body).toMatchObject({ code: "txid_already_verified", retryable: false, @@ -200,7 +200,7 @@ describe("POST /api/competition/trades — idempotent re-submit → 409", () => row: ROW, }); const res = await POST(buildRequest({ txid: TXID })); - const body = await res.json(); + const body = (await res.json()) as any; // ROW.source === "cron" — caller can see cron wrote the row first, // not agent-submit. Useful diagnostic. expect(body.existing_row.source).toBe("cron"); @@ -223,7 +223,7 @@ describe("POST /api/competition/trades — idempotent re-submit → 409", () => const res3 = await POST(buildRequest({ txid: TXID })); expect(res3.status).toBe(200); - const body3 = await res3.json(); + const body3 = (await res3.json()) as any; expect(body3).toEqual(freshRow); // 200 body is the row alone — no error / existing_row fields. expect(body3.error).toBeUndefined(); @@ -231,7 +231,7 @@ describe("POST /api/competition/trades — idempotent re-submit → 409", () => const res4 = await POST(buildRequest({ txid: TXID })); expect(res4.status).toBe(409); - const body4 = await res4.json(); + const body4 = (await res4.json()) as any; expect(body4.code).toBe("txid_already_verified"); expect(body4.existing_row).toEqual(freshRow); @@ -262,7 +262,7 @@ describe("POST /api/competition/trades — verify result → HTTP mapping", () = (verifyAndPersistSwap as Mock).mockResolvedValue({ status: "verified", inserted: true, row }); const res = await POST(buildRequest({ txid: TXID })); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as any; expect(body).toEqual(row); }); @@ -275,7 +275,7 @@ describe("POST /api/competition/trades — verify result → HTTP mapping", () = }); const res = await POST(buildRequest({ txid: TXID })); expect(res.status).toBe(422); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.code).toBe("sender_not_registered"); expect(body.retryable).toBe(false); }); @@ -312,7 +312,7 @@ describe("POST /api/competition/trades — verify result → HTTP mapping", () = const res = await POST(buildRequest({ txid: TXID })); expect(res.status).toBe(502); expect(res.headers.get("Retry-After")).toBe("5"); - const body = await res.json(); + const body = (await res.json()) as any; expect(body.retryable).toBe(true); }); diff --git a/app/api/competition/cron/route.ts b/app/api/competition/cron/route.ts deleted file mode 100644 index f2858eca..00000000 --- a/app/api/competition/cron/route.ts +++ /dev/null @@ -1,83 +0,0 @@ -// CACHE_INVARIANTS:POSTURE=auth-required -// Cron catch-up endpoint. Shared-secret authenticated; never cached. -// Triggered by an external scheduler (e.g. Cloudflare Cron Trigger via fetch). - -import { NextRequest, NextResponse } from "next/server"; -import { getCloudflareContext } from "@opennextjs/cloudflare"; -import { createLogger, createConsoleLogger, isLogsRPC } from "@/lib/logging"; -import { runCompetitionCron } from "@/lib/competition/cron"; - -export async function GET() { - return NextResponse.json( - { - endpoint: "/api/competition/cron", - methods: ["POST"], - description: - "15-min catch-up sweep for the trading-comp verifier. Walks registered_wallets and re-fetches recent Hiro tx history, filtering by allowlist and submitting each match via verifyAndPersistSwap with source='cron'. Pairs with POST /api/competition/trades (agent-submit fast path) — first writer wins on (txid).", - auth: { - scheme: "Shared secret", - header: "X-Cron-Secret: {env.CRON_SECRET}", - }, - response: { - scanned: "number (addresses walked this run)", - found: "number (allowlisted txs touching those addresses)", - inserted: "number (new rows written to swaps)", - alreadyKnown: "number (rows that existed from another ingestion path)", - pending: "number (txs Hiro reported as still in flight)", - rejected: "number (verifier rejected — sender/allowlist/parse failure)", - cursor: "string | null (next stx_address to resume from)", - }, - notes: [ - "Per-run cap: 100 addresses (CRON_MAX_ADDRESSES_PER_RUN). Sized for a 15-min cadence — the full membership cycles in roughly 5 runs at the current scale.", - "The sweep resumes across runs via D1 (competition_state.cron_cursor).", - "wrangler cron-trigger wiring is tracked as a follow-up; this route is callable today via HTTPS with the shared secret.", - ], - }, - { headers: { "Cache-Control": "no-store" } } - ); -} - -export async function POST(request: NextRequest) { - const { env, ctx } = await getCloudflareContext(); - const rayId = request.headers.get("cf-ray") || crypto.randomUUID(); - const logger = isLogsRPC(env.LOGS) - ? createLogger(env.LOGS, ctx, { rayId, path: request.nextUrl.pathname }) - : createConsoleLogger({ rayId, path: request.nextUrl.pathname }); - - const expectedSecret = env.CRON_SECRET; - if (!expectedSecret) { - logger.error("CRON_SECRET not configured"); - return NextResponse.json( - { error: "Server configuration error" }, - { status: 500, headers: { "Cache-Control": "no-store" } } - ); - } - - const provided = request.headers.get("x-cron-secret"); - if (!provided || provided !== expectedSecret) { - return NextResponse.json( - { error: "Invalid or missing X-Cron-Secret" }, - { status: 401, headers: { "Cache-Control": "no-store" } } - ); - } - - const db = env.DB as D1Database | undefined; - if (!db) { - logger.warn("D1 binding missing on competition/cron"); - return NextResponse.json( - { - error: "transient_d1_unavailable", - message: "Competition database temporarily unavailable. Please retry shortly.", - retry_after: 60, - }, - { status: 503, headers: { "Retry-After": "60", "Cache-Control": "no-store" } } - ); - } - - const summary = await runCompetitionCron( - { DB: db, HIRO_API_KEY: env.HIRO_API_KEY }, - logger - ); - - return NextResponse.json(summary, { headers: { "Cache-Control": "no-store" } }); -} diff --git a/app/api/competition/trades/route.ts b/app/api/competition/trades/route.ts index 12bd5843..fecdac58 100644 --- a/app/api/competition/trades/route.ts +++ b/app/api/competition/trades/route.ts @@ -28,7 +28,7 @@ function selfDocResponse() { endpoint: "/api/competition/trades", methods: ["GET", "POST"], description: - "Trading-comp swap history. GET returns paginated trades; POST verifies a swap by txid (ships in Phase 3.1 PR-B; currently 501 Not Implemented).", + "Trading-comp swap history. GET returns paginated trades; POST verifies a swap by txid.", get: { queryParameters: { docs: { @@ -66,7 +66,7 @@ function selfDocResponse() { amount_out: "number (raw on-chain units)", burn_block_time: "number (unix seconds)", tx_status: "string (success | abort_by_response | …)", - source: "string ('agent' | 'cron' | 'chainhook')", + source: "string ('agent' | 'cron' | 'chainhook'; 'cron' is SchedulerDO catch-up)", scored_value: "number | null", scored_at: "string | null (ISO-8601)", }, @@ -76,7 +76,7 @@ function selfDocResponse() { }, post: { description: - "Submit a confirmed Stacks txid for verification. Callers (typically the AIBTC MCP server) must pre-check that the tx is terminal before submitting; the route checks D1 first (cheap idempotency gate), then fetches the tx from Hiro, runs sender + allowlist checks, parses the swap, and persists via INSERT OR IGNORE. First writer wins on `(txid)` across all ingestion paths (agent / cron); re-submits of an already-recorded txid return 409.", + "Submit a confirmed Stacks txid for verification. Callers (typically the AIBTC MCP server) must pre-check that the tx is terminal before submitting; the route checks D1 first (cheap idempotency gate), then fetches the tx from Hiro, runs sender + allowlist checks, parses the swap, and persists via INSERT OR IGNORE. First writer wins on `(txid)` across all ingestion paths (agent / scheduler); re-submits of an already-recorded txid return 409.", requestBody: { txid: "string — 64-char hex (0x-prefixed accepted)" }, responses: { "200": "First-time verified — body is the persisted SwapRow", @@ -219,8 +219,9 @@ export async function GET(request: NextRequest) { * POST /api/competition/trades — agent-submit verifier (Phase 3.1 PR-B). * * Accepts { txid } and runs the single-tx verifier (see lib/competition/verify.ts). - * - 202 { accepted: true } when the tx is still pending (KV-tracked, 30-min TTL) - * - 200 with the persisted row when verified (newly written OR idempotent re-submission) + * - 202 { accepted: true } when Hiro has not propagated terminal status yet + * - 200 with the persisted row when verified (newly written) + * - 409 with the existing row on idempotent re-submission * - 422 with { error, code, retryable: false } on sender/allowlist/parse rejections * - 4xx on malformed input, 429 on rate limit, 503 on D1 unavailability */ @@ -308,7 +309,7 @@ export async function POST(request: NextRequest) { // the verifier. Return 409 Conflict (not 200) so the caller has an // unambiguous signal that this submit did NOT write the row. The // existing row is included so callers can reconcile (its `source` - // identifies which ingestion path wrote first — agent / cron / + // identifies which ingestion path wrote first — agent / scheduler / // chainhook). retryable: false because re-POSTing the same txid will // keep landing here. if (!result.inserted) { diff --git a/app/api/openapi.json/route.ts b/app/api/openapi.json/route.ts index b17e1fd5..f381a520 100644 --- a/app/api/openapi.json/route.ts +++ b/app/api/openapi.json/route.ts @@ -1188,7 +1188,11 @@ export function GET() { amount_out: { type: "integer" }, burn_block_time: { type: "integer", description: "Unix seconds" }, tx_status: { type: "string" }, - source: { type: "string", enum: ["agent", "cron", "chainhook"] }, + source: { + type: "string", + enum: ["agent", "cron", "chainhook"], + description: "`cron` is the legacy schema label for SchedulerDO catch-up.", + }, scored_value: { type: ["integer", "null"] }, scored_at: { type: ["string", "null"] }, }, @@ -1234,7 +1238,7 @@ export function GET() { "confirmation before submitting; the route checks D1 first (cheap idempotency gate), " + "fetches the tx from Hiro, runs sender + allowlist checks, parses the FT/STX transfer " + "events, and persists via INSERT OR IGNORE on (txid). First writer wins across the two " + - "active ingestion paths (agent / cron); re-submits of an already-recorded txid return 409 " + + "active ingestion paths (agent / scheduler); re-submits of an already-recorded txid return 409 " + "with the existing row. Rate limit: 20/min per IP (RATE_LIMIT_MUTATING).", requestBody: { required: true, @@ -1310,51 +1314,6 @@ export function GET() { }, }, }, - "/api/competition/cron": { - post: { - operationId: "runCompetitionCron", - summary: "Run the 15-min catch-up sweep (shared-secret authenticated)", - description: - "Walks registered_wallets, fetches recent Hiro tx history per address, filters by allowlist, " + - "and submits each match via the verifier with `source='cron'`. Pairs with the agent-submit fast " + - "path (POST /api/competition/trades) — first writer wins on `(txid)`. Per-run cap: 100 " + - "addresses, sized for a 15-min cadence at the current ~430 registered wallets. Resumes across " + - "runs via the `comp:cron:cursor` KV key.", - parameters: [ - { - name: "X-Cron-Secret", - in: "header", - required: true, - description: "Shared secret matching env.CRON_SECRET.", - schema: { type: "string" }, - }, - ], - responses: { - "200": { - description: "Sweep complete — body is the run summary", - content: { - "application/json": { - schema: { - type: "object", - properties: { - scanned: { type: "integer" }, - found: { type: "integer" }, - inserted: { type: "integer" }, - alreadyKnown: { type: "integer" }, - pending: { type: "integer" }, - rejected: { type: "integer" }, - cursor: { type: ["string", "null"] }, - }, - }, - }, - }, - }, - "401": { description: "Missing or invalid X-Cron-Secret" }, - "500": { description: "Server config error (CRON_SECRET not set)" }, - "503": { description: "D1 temporarily unavailable" }, - }, - }, - }, "/api/levels": { get: { operationId: "getLevelSystem", diff --git a/app/llms-full.txt/route.ts b/app/llms-full.txt/route.ts index 54c15644..d09e5a03 100644 --- a/app/llms-full.txt/route.ts +++ b/app/llms-full.txt/route.ts @@ -874,7 +874,7 @@ Verifier surface for the AIBTC trading competition. Read + write routes are live Data model: swaps are persisted to a D1 \`swaps\` table on terminal status only. Pending/in-flight swaps are NOT stored (migration 005 forbids it). Two ingestion paths converge on the same row via INSERT OR IGNORE on \`txid\`: agent-submit -(POST /api/competition/trades) and the 15-min catch-up cron. The \`source\` column +(POST /api/competition/trades) and the SchedulerDO catch-up sweep. The \`source\` column records who got there first. A third value \`'chainhook'\` is reserved in the enum for a future real-time stream if/when product surfaces require sub-minute freshness (current cadence is plenty for hourly leaderboards). Mainnet-only in @@ -957,8 +957,8 @@ Response matrix: - \`409\` — **already verified**: this txid is already in the swaps table. Body: \`{ error, code: "txid_already_verified", retryable: false, existing_row }\`. The \`existing_row.source\` identifies which ingestion path wrote first - (\`agent\` if you / another agent-submit got there first; \`cron\` if the 15-min - catch-up beat you). retryable:false — re-POSTing will keep landing here. + (\`agent\` if you / another agent-submit got there first; \`cron\` if the + SchedulerDO catch-up beat you). retryable:false — re-POSTing will keep landing here. - \`202 { accepted: true, note }\` — fallback for the racy edge case where the caller saw the tx as confirmed but Hiro hasn't propagated it as terminal yet. Should be rare; retry in a few seconds. @@ -973,21 +973,20 @@ The route checks D1 before hitting Hiro — re-submits of an already-verified txid resolve to a 409 in a single D1 read (no upstream call, no wasted Hiro quota). -### Cron Catch-Up (operator-only) +### Scheduler Catch-Up -\`POST /api/competition/cron\` runs the 15-min catch-up sweep. Walks -\`registered_wallets\` (100 addresses per run, resumes via \`comp:cron:cursor\` KV -key), fetches each address's recent Hiro tx history, filters by allowlist, and -submits matches with \`source='cron'\`. Shared-secret authenticated via -\`X-Cron-Secret\`. Picks up anything the agent-submit fast path missed within a -quarter hour of confirmation. - -Response: \`{ scanned, found, inserted, alreadyKnown, pending, rejected, cursor }\`. +The SchedulerDO runs the 15-min catch-up sweep. It walks \`registered_wallets\` +(100 addresses per run, resumes via D1 \`competition_state\`), fetches each +address's recent Hiro tx history, filters by allowlist, and submits matches with +\`source='cron'\` for schema compatibility. Operators can trigger it manually via +the admin scheduler endpoint: \`POST /api/admin/scheduler?action=refresh&task=competition\`. +No public shared-secret competition route is exposed. ### Schema Notes - \`source\` enum: \`'agent' | 'cron' | 'chainhook'\`. \`'agent'\` and \`'cron'\` are - written today; \`'chainhook'\` is reserved for a future real-time path (no + written today; \`'cron'\` is the legacy schema label for the SchedulerDO catch-up + writer. \`'chainhook'\` is reserved for a future real-time path (no receiver route in Phase 3.1 — the schema slot stays so we don't migrate later). Idempotent re-submission from a different source does NOT overwrite \`source\`. - \`tx_status\` includes all terminal Stacks tx statuses (success + diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index 07862178..384fbb2f 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -124,7 +124,6 @@ All endpoints return self-documenting JSON on GET. - GET /api/competition/status?address={stx} — membership + verified trade counts (unregistered → \`registered: false\`, not 404) - GET /api/competition/trades?address={stx}&limit=50&cursor=… — paginated swap history (keyset over burn_block_time, txid) - POST /api/competition/trades — submit a txid for verification (Hiro fetch + allowlist + INSERT OR IGNORE; 202 if pending, 200 if verified, 422 if rejected) -- POST /api/competition/cron — 15-min catch-up sweep (shared-secret, operator-only) ### Progression (Free) @@ -249,7 +248,7 @@ Existing agents can retroactively claim a referral: \`POST /api/vouch\` with \`{ ### Trading Competition - [Comp Status](https://aibtc.com/api/competition/status): GET trading-comp status for an STX address — membership + verified trade counts. Unregistered addresses return \`{ registered: false }\` (not 404). Free. -- [Comp Trades](https://aibtc.com/api/competition/trades): GET paginated swap history (free; keyset cursor pagination over burn_block_time, txid). POST submits a txid for verification — ships in Phase 3.1 PR-B (currently 501 Not Implemented). +- [Comp Trades](https://aibtc.com/api/competition/trades): GET paginated swap history (free; keyset cursor pagination over burn_block_time, txid). POST submits a txid for verification. ### System diff --git a/lib/competition/__tests__/cron.test.ts b/lib/competition/__tests__/scheduler.test.ts similarity index 90% rename from lib/competition/__tests__/cron.test.ts rename to lib/competition/__tests__/scheduler.test.ts index f96af145..4ad7f235 100644 --- a/lib/competition/__tests__/cron.test.ts +++ b/lib/competition/__tests__/scheduler.test.ts @@ -1,7 +1,7 @@ /** - * Tests for lib/competition/cron.ts + * Tests for lib/competition/scheduler.ts * - * Phase 3.1 PR-D — exercises the catch-up sweep's walk + dispatch + * Phase 3.1 PR-D — exercises the scheduler sweep's walk + dispatch * + cursor-persistence logic. Hiro fetch is injected; verify is mocked. */ @@ -12,9 +12,9 @@ vi.mock("../verify", () => ({ })); import { - runCompetitionCron, - CRON_MAX_ADDRESSES_PER_RUN, -} from "../cron"; + runCompetitionScheduler, + COMPETITION_SCHEDULER_MAX_ADDRESSES_PER_RUN, +} from "../scheduler"; import { verifyAndPersistSwap } from "../verify"; import type { Mock } from "vitest"; @@ -94,7 +94,7 @@ beforeEach(() => { vi.clearAllMocks(); }); -describe("runCompetitionCron — walk + dispatch", () => { +describe("runCompetitionScheduler — walk + dispatch", () => { it("walks the address page, finds allowlisted txs, and submits them with source='cron'", async () => { const { db } = makeDb(["SP_ADDR_001"]); (verifyAndPersistSwap as Mock).mockResolvedValue({ @@ -111,7 +111,7 @@ describe("runCompetitionCron — walk + dispatch", () => { }, ]); - const summary = await runCompetitionCron( + const summary = await runCompetitionScheduler( { DB: db, HIRO_API_KEY: undefined }, undefined, { fetchAddressTxsImpl } @@ -139,7 +139,7 @@ describe("runCompetitionCron — walk + dispatch", () => { }, ]); - const summary = await runCompetitionCron( + const summary = await runCompetitionScheduler( { DB: db }, undefined, { fetchAddressTxsImpl } @@ -155,7 +155,7 @@ describe("runCompetitionCron — walk + dispatch", () => { { tx_id: "0xaaa", tx_type: "token_transfer" }, ]); - const summary = await runCompetitionCron( + const summary = await runCompetitionScheduler( { DB: db }, undefined, { fetchAddressTxsImpl } @@ -179,7 +179,7 @@ describe("runCompetitionCron — walk + dispatch", () => { { tx_id: "0xddd", tx_type: "contract_call", contract_call: { contract_id: ALLOWED_CONTRACT, function_name: ALLOWED_FN } }, ]); - const summary = await runCompetitionCron( + const summary = await runCompetitionScheduler( { DB: db }, undefined, { fetchAddressTxsImpl } @@ -196,16 +196,16 @@ describe("runCompetitionCron — walk + dispatch", () => { }); }); -describe("runCompetitionCron — cursor persistence", () => { +describe("runCompetitionScheduler — cursor persistence", () => { it("persists the next cursor when the page is full (more addresses to walk)", async () => { const fullPage: string[] = Array.from( - { length: CRON_MAX_ADDRESSES_PER_RUN }, + { length: COMPETITION_SCHEDULER_MAX_ADDRESSES_PER_RUN }, (_, i) => `SP_ADDR_${String(i).padStart(3, "0")}` ); const { db, cursorOps } = makeDb(fullPage); const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); - const summary = await runCompetitionCron( + const summary = await runCompetitionScheduler( { DB: db }, undefined, { fetchAddressTxsImpl } @@ -223,7 +223,7 @@ describe("runCompetitionCron — cursor persistence", () => { ); const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); - const summary = await runCompetitionCron( + const summary = await runCompetitionScheduler( { DB: db }, undefined, { fetchAddressTxsImpl } @@ -238,7 +238,7 @@ describe("runCompetitionCron — cursor persistence", () => { const { db } = makeDb([], { initialCursor: "SP_LAST_RUN" }); const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); - await runCompetitionCron( + await runCompetitionScheduler( { DB: db }, undefined, { fetchAddressTxsImpl } @@ -252,7 +252,7 @@ describe("runCompetitionCron — cursor persistence", () => { const { db } = makeDb([]); const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); - await runCompetitionCron( + await runCompetitionScheduler( { DB: db }, undefined, { fetchAddressTxsImpl } @@ -264,7 +264,7 @@ describe("runCompetitionCron — cursor persistence", () => { }); }); -describe("runCompetitionCron — fault tolerance", () => { +describe("runCompetitionScheduler — fault tolerance", () => { it("counts a verify throw as rejected and continues the sweep", async () => { const { db } = makeDb(["SP_ADDR_001"]); (verifyAndPersistSwap as Mock).mockRejectedValueOnce(new Error("boom")); @@ -273,7 +273,7 @@ describe("runCompetitionCron — fault tolerance", () => { { tx_id: "0xaaa", tx_type: "contract_call", contract_call: { contract_id: ALLOWED_CONTRACT, function_name: ALLOWED_FN } }, ]); - const summary = await runCompetitionCron( + const summary = await runCompetitionScheduler( { DB: db }, undefined, { fetchAddressTxsImpl } diff --git a/lib/competition/constants.ts b/lib/competition/constants.ts index e9ff147b..8a3a37a5 100644 --- a/lib/competition/constants.ts +++ b/lib/competition/constants.ts @@ -4,7 +4,7 @@ * Trades with `burn_block_time < COMP_START_TIMESTAMP` are rejected by the * verifier (code: `before_comp_start`) even if otherwise valid — they * pre-date the campaign window. This is a hard correctness gate so that - * neither the agent-submit fast path nor the cron's catch-up pass can + * neither the agent-submit fast path nor the scheduler catch-up pass can * pollute `swaps` with pre-campaign history. * * 1778630400 = 2026-05-13T00:00:00Z. diff --git a/lib/competition/d1-reads.ts b/lib/competition/d1-reads.ts index c9214dc6..3590e0a5 100644 --- a/lib/competition/d1-reads.ts +++ b/lib/competition/d1-reads.ts @@ -2,7 +2,7 @@ * D1 read helpers for the trading-comp verifier surface. * * Phase 3.1 PR-A — read routes only. The verifier (POST /api/competition/trades) - * ships in PR-B; chainhook + cron + allowlist in PR-C + PR-D. + * ships in PR-B; scheduler catch-up + allowlist in PR-D. * * Read contract (locked, per PHASE-3.1-HANDOFF.md): * - GET /api/competition/status → getCompetitionStatusFromD1 diff --git a/lib/competition/cron.ts b/lib/competition/scheduler.ts similarity index 73% rename from lib/competition/cron.ts rename to lib/competition/scheduler.ts index b918c55a..3f34b046 100644 --- a/lib/competition/cron.ts +++ b/lib/competition/scheduler.ts @@ -1,10 +1,10 @@ /** - * 15-min catch-up cron — walks registered_wallets and re-verifies recent - * Hiro tx history. Phase 3.1 PR-D. + * Competition scheduler catch-up sweep — walks registered_wallets and + * re-verifies recent Hiro tx history. Phase 3.1 PR-D. * * Pairs with the agent-submit fast path (POST /api/competition/trades): * agent-submit catches everything the agent does (the agent already knows - * its own txid); this cron catches everything the fast path missed. + * its own txid); this scheduler catches everything the fast path missed. * Both converge on the same `swaps` row via INSERT OR IGNORE on `txid` — * first writer wins; second writer is a no-op. * @@ -12,7 +12,7 @@ * - Max 100 addresses per execution. Tuned for a 15-min cadence: at the * current ~430 registered wallets, the full list cycles in roughly * 5 runs (~75 min). Single Hiro client, single rate-limit budget. - * - Resume from D1 cursor (`competition_state.cron_cursor`) so subsequent + * - Resume from D1 cursor (`competition_state.competition_scheduler_cursor`) so subsequent * runs continue where the previous one stopped. Moved from KV to D1 * per @whoabuddy's #738 review note that cursor state belongs alongside * the data it gates. @@ -20,10 +20,8 @@ * Returns a structured summary for the logs: * { scanned, found, inserted, alreadyKnown, rejected, pending, cursor } * - * The wrangler scheduled trigger registration (and the bridge from the - * Worker's scheduled() entrypoint to this code) is infrastructure wiring - * tracked as a follow-up — the route is callable directly via HTTPS with - * a shared-secret header for now. + * SchedulerDO owns cadence and manual refresh. No public operator endpoint + * or shared-secret route is required. */ import type { Logger } from "@/lib/logging"; @@ -31,15 +29,19 @@ import { stacksApiFetch } from "@/lib/stacks-api-fetch"; import { STACKS_API_BASE } from "@/lib/identity/constants"; import { verifyAndPersistSwap } from "./verify"; import { isAllowedSwap } from "./allowlist"; -import { getCronCursor, setCronCursor, clearCronCursor } from "./state"; +import { + clearCompetitionSchedulerCursor, + getCompetitionSchedulerCursor, + setCompetitionSchedulerCursor, +} from "./state"; -/** Per-run cap on addresses scanned. Cron resumes from the D1 cursor next run. */ -export const CRON_MAX_ADDRESSES_PER_RUN = 100; +/** Per-run cap on addresses scanned. Scheduler resumes from the D1 cursor next run. */ +export const COMPETITION_SCHEDULER_MAX_ADDRESSES_PER_RUN = 100; /** Per-address tx history page size. */ const HIRO_TX_PAGE_LIMIT = 25; -export interface CronSummary { +export interface CompetitionSchedulerSummary { scanned: number; found: number; inserted: number; @@ -71,7 +73,7 @@ async function fetchAddressTxs( try { const response = await stacksApiFetch(url, { method: "GET", headers }, { logger }); if (!response.ok) { - logger?.warn?.("competition.cron.hiro_non_ok", { + logger?.warn?.("competition.scheduler.hiro_non_ok", { stxAddress, status: response.status, }); @@ -80,7 +82,7 @@ async function fetchAddressTxs( const body = (await response.json()) as { results?: AddressTxEntry[] }; return body.results ?? []; } catch (err) { - logger?.warn?.("competition.cron.hiro_threw", { + logger?.warn?.("competition.scheduler.hiro_threw", { stxAddress, error: String(err), }); @@ -90,7 +92,7 @@ async function fetchAddressTxs( /** * Page through registered_wallets starting from the cursor. Returns up to - * CRON_MAX_ADDRESSES_PER_RUN rows ordered by stx_address ASC so the cursor + * COMPETITION_SCHEDULER_MAX_ADDRESSES_PER_RUN rows ordered by stx_address ASC so the cursor * is monotonic. When the walk wraps (no more rows after cursor), the next * call returns the head of the list and the cursor resets to null. */ @@ -102,41 +104,41 @@ async function fetchAddressPage( ? `SELECT stx_address FROM registered_wallets WHERE stx_address > ?1 ORDER BY stx_address ASC LIMIT ?2` : `SELECT stx_address FROM registered_wallets ORDER BY stx_address ASC LIMIT ?1`; const stmt = cursor - ? db.prepare(sql).bind(cursor, CRON_MAX_ADDRESSES_PER_RUN) - : db.prepare(sql).bind(CRON_MAX_ADDRESSES_PER_RUN); + ? db.prepare(sql).bind(cursor, COMPETITION_SCHEDULER_MAX_ADDRESSES_PER_RUN) + : db.prepare(sql).bind(COMPETITION_SCHEDULER_MAX_ADDRESSES_PER_RUN); const result = await stmt.all<{ stx_address: string }>(); const rows = result.results ?? []; let nextCursor: string | null = null; - if (rows.length === CRON_MAX_ADDRESSES_PER_RUN) { + if (rows.length === COMPETITION_SCHEDULER_MAX_ADDRESSES_PER_RUN) { nextCursor = rows[rows.length - 1].stx_address; } return { rows, nextCursor }; } -export interface RunCronOptions { +export interface RunCompetitionSchedulerOptions { /** Inject a custom address-history fetcher (for tests). */ fetchAddressTxsImpl?: typeof fetchAddressTxs; } /** - * Execute one cron sweep. + * Execute one scheduler sweep. * * The handoff: walk registered_wallets, fetch recent Hiro history per * address, filter by allowlist, submit each match via verifyAndPersistSwap - * with source='cron'. The KV cursor lets the sweep resume across runs + * with source='cron'. The D1 cursor lets the sweep resume across runs * rather than always starting at the head. */ -export async function runCompetitionCron( +export async function runCompetitionScheduler( env: { DB: D1Database; HIRO_API_KEY?: string }, logger?: Logger, - options: RunCronOptions = {} -): Promise { + options: RunCompetitionSchedulerOptions = {} +): Promise { const txsFetcher = options.fetchAddressTxsImpl ?? fetchAddressTxs; - const cursor = await getCronCursor(env.DB); + const cursor = await getCompetitionSchedulerCursor(env.DB); const { rows, nextCursor } = await fetchAddressPage(env.DB, cursor); - const summary: CronSummary = { + const summary: CompetitionSchedulerSummary = { scanned: rows.length, found: 0, inserted: 0, @@ -166,7 +168,7 @@ export async function runCompetitionCron( } } catch (err) { summary.rejected++; - logger?.warn?.("competition.cron.verify_threw", { + logger?.warn?.("competition.scheduler.verify_threw", { stxAddress: stx_address, txid: tx.tx_id, error: String(err), @@ -178,11 +180,11 @@ export async function runCompetitionCron( // Persist next cursor. When nextCursor is null (we walked the tail), // clear the row so the next run starts fresh at the head. if (nextCursor) { - await setCronCursor(env.DB, nextCursor); + await setCompetitionSchedulerCursor(env.DB, nextCursor); } else { - await clearCronCursor(env.DB); + await clearCompetitionSchedulerCursor(env.DB); } - logger?.info?.("competition.cron.summary", { ...summary }); + logger?.info?.("competition.scheduler.summary", { ...summary }); return summary; } diff --git a/lib/competition/state.ts b/lib/competition/state.ts index 931e5e63..34690e3a 100644 --- a/lib/competition/state.ts +++ b/lib/competition/state.ts @@ -1,42 +1,45 @@ /** - * D1-backed persistent state for the competition cron. + * D1-backed persistent state for the competition scheduler. * - * Replaces the KV `comp:cron:cursor` key (formerly under `VERIFIED_AGENTS`) + * Replaces the old KV cursor key (formerly under `VERIFIED_AGENTS`) * with a row in the `competition_state` table from migration 009. Same - * three-op surface (get / set / clear) so the cron only changes which + * three-op surface (get / set / clear) so the scheduler only changes which * driver it imports. * * Why D1 instead of KV: see PR #738 review thread * (https://github.com/aibtcdev/landing-page/pull/738#issuecomment-4426307229). * Cursor state belongs in the same store as the data it gates so we can * audit + recover from a single source, and so future scheduler - * primitives (Cron Trigger, DO alarm, whatever) all read the same row. + * primitives all read the same row. */ -const CRON_CURSOR_KEY = "cron_cursor"; +const COMPETITION_SCHEDULER_CURSOR_KEY = "competition_scheduler_cursor"; -export async function getCronCursor(db: D1Database): Promise { +export async function getCompetitionSchedulerCursor(db: D1Database): Promise { const row = await db .prepare(`SELECT value FROM competition_state WHERE key = ?1`) - .bind(CRON_CURSOR_KEY) + .bind(COMPETITION_SCHEDULER_CURSOR_KEY) .first<{ value: string }>(); return row?.value ?? null; } -export async function setCronCursor(db: D1Database, cursor: string): Promise { +export async function setCompetitionSchedulerCursor( + db: D1Database, + cursor: string +): Promise { await db .prepare( `INSERT INTO competition_state (key, value, updated_at) VALUES (?1, ?2, unixepoch()) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at` ) - .bind(CRON_CURSOR_KEY, cursor) + .bind(COMPETITION_SCHEDULER_CURSOR_KEY, cursor) .run(); } -export async function clearCronCursor(db: D1Database): Promise { +export async function clearCompetitionSchedulerCursor(db: D1Database): Promise { await db .prepare(`DELETE FROM competition_state WHERE key = ?1`) - .bind(CRON_CURSOR_KEY) + .bind(COMPETITION_SCHEDULER_CURSOR_KEY) .run(); } diff --git a/lib/competition/verify.ts b/lib/competition/verify.ts index 797ff659..e8380c84 100644 --- a/lib/competition/verify.ts +++ b/lib/competition/verify.ts @@ -1,19 +1,17 @@ /** * Single-tx verifier for the trading-comp surface. * - * `verifyAndPersistSwap` is the shared entry point used by all three - * ingestion paths (agent-submit POST, chainhook, nightly cron). It takes a + * `verifyAndPersistSwap` is the shared entry point used by the ingestion + * paths (agent-submit POST and SchedulerDO catch-up). It takes a * txid, fetches the Hiro tx, runs sender + allowlist checks, parses the * swap, and persists via INSERT OR IGNORE (first writer wins on `(txid)`). * - * Phase 3.1 PR-B — agent-submit POST is the first caller. Chainhook (PR-C) - * and cron (PR-D) re-use the same function with a different `source`. + * Phase 3.1 PR-B — agent-submit POST is the first caller. SchedulerDO reuses + * the same function with a different `source`. * * Return shape is a discriminated result so callers can map it to: * - 200 with the persisted row (verified / idempotent re-submission) - * - 202 with { accepted: true } when the tx is still pending — the - * caller is responsible for writing the KV `comp:pending:{txid}` key - * since the pending tracker is route-layer concern (KV, not D1). + * - 202 with { accepted: true } when the tx is still pending. * - 4xx structured rejection (sender_not_registered, contract_not_allowlisted, * tx_failed, malformed) * @@ -85,7 +83,7 @@ interface PersistArgs { * Sender check is the first cheap gate before any Hiro round-trip would * normally apply — but in our flow we already have the tx fetched, so this * runs after the fetch. Keeping it as a SELECT 1 not a JOIN keeps the - * shape ergonomic for chainhook/cron callers that may want to batch. + * shape ergonomic for scheduler callers that may want to batch. */ async function senderIsRegistered( db: D1Database, @@ -284,7 +282,7 @@ export async function verifyAndPersistSwap( // Comp-start gate. Trades whose burn_block_time predates the campaign // window do not count, regardless of other validity. Reject before the - // sender / allowlist / parse stages so the cron's catch-up pass can't + // sender / allowlist / parse stages so the scheduler catch-up pass can't // backfill pre-campaign history into `swaps` either. const txBurnTime = tx.burn_block_time ?? 0; if (txBurnTime < COMP_START_TIMESTAMP) { diff --git a/lib/scheduler/rpc-types.ts b/lib/scheduler/rpc-types.ts index 575a0795..0b4f7938 100644 --- a/lib/scheduler/rpc-types.ts +++ b/lib/scheduler/rpc-types.ts @@ -1,19 +1,23 @@ import type { TeneroRunResult } from "./tenero-task"; +import type { CompetitionSchedulerSummary } from "../competition/scheduler"; -export type SchedulerTask = "tenero" | "all"; +export type SchedulerTask = "tenero" | "competition" | "all"; export interface SchedulerStatus { now: number; pausedUntil: number | null; lastTeneroRunAt: number | null; lastTeneroResult: TeneroRunResult | null; - consecutiveFailures: { tenero: number }; - nextRunAfter: { tenero: number | null }; + lastCompetitionRunAt: number | null; + lastCompetitionResult: CompetitionSchedulerSummary | null; + consecutiveFailures: { tenero: number; competition: number }; + nextRunAfter: { tenero: number | null; competition: number | null }; nextAlarmAt: number | null; } export interface SchedulerRefreshResult { tenero?: TeneroRunResult; + competition?: CompetitionSchedulerSummary; } export interface SchedulerRpc { diff --git a/migrations/009_competition_state.sql b/migrations/009_competition_state.sql index c1af9ffb..2ca92d7b 100644 --- a/migrations/009_competition_state.sql +++ b/migrations/009_competition_state.sql @@ -1,11 +1,11 @@ -- Migration 009: competition_state table. --- Tiny K/V scratchpad for the competition cron's persistent state. --- Replaces KV `comp:cron:cursor` per @whoabuddy's #738 review note that +-- Tiny K/V scratchpad for the competition scheduler's persistent state. +-- Replaces the old KV cursor pattern per @whoabuddy's #738 review note that -- "we need cursor state" (https://github.com/aibtcdev/landing-page/pull/738#issuecomment-4426307229): -- queryable, durable, and lives in the same store as everything else the -- comp surface reads/writes. -- --- Schema is intentionally generic — future cron state (last_run_at, +-- Schema is intentionally generic — future scheduler state (last_run_at, -- consecutive_failures, etc.) reuses the same table rather than spawning -- one tiny migration per signal. diff --git a/worker.ts b/worker.ts index 78ec56de..72dcb4a5 100644 --- a/worker.ts +++ b/worker.ts @@ -18,6 +18,10 @@ import { type TeneroRunResult, TENERO_MINUTE_QUOTA_BACKOFF_MS, } from "./lib/scheduler/tenero-task"; +import { + runCompetitionScheduler, + type CompetitionSchedulerSummary, +} from "./lib/competition/scheduler"; import type { SchedulerRefreshResult, SchedulerStatus, @@ -37,24 +41,31 @@ import type { // 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 +// - lastTeneroRunAt — unix millis when the Tenero task last completed +// - lastTeneroResult — { succeeded, failed, minuteRemaining, monthRemaining } +// - lastCompetitionRunAt — unix millis when the competition sweep last completed +// - lastCompetitionResult — { scanned, found, inserted, alreadyKnown, pending, rejected, cursor } +// - consecutiveFailures — { tenero: number, competition: number } +// - pausedUntil — unix millis; alarm() is a no-op until this passes +// - nextRunAfter — 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 COMPETITION_INTERVAL_MS = 15 * 60 * 1000; const ALARM_TICK_MS = TENERO_INTERVAL_MS; +type SchedulerFailureTask = "tenero" | "competition"; + type StoredScheduler = { lastTeneroRunAt?: number; lastTeneroResult?: TeneroRunResult; - consecutiveFailures?: { tenero: number }; + lastCompetitionRunAt?: number; + lastCompetitionResult?: CompetitionSchedulerSummary; + consecutiveFailures?: Partial>; pausedUntil?: number; - nextRunAfter?: { tenero?: number }; + nextRunAfter?: Partial>; }; export class SchedulerDO extends DurableObject { @@ -78,18 +89,29 @@ export class SchedulerDO extends DurableObject { pausedUntil: s.pausedUntil ?? null, lastTeneroRunAt: s.lastTeneroRunAt ?? null, lastTeneroResult: s.lastTeneroResult ?? null, - consecutiveFailures: { tenero: s.consecutiveFailures?.tenero ?? 0 }, - nextRunAfter: { tenero: s.nextRunAfter?.tenero ?? null }, + lastCompetitionRunAt: s.lastCompetitionRunAt ?? null, + lastCompetitionResult: s.lastCompetitionResult ?? null, + consecutiveFailures: { + tenero: s.consecutiveFailures?.tenero ?? 0, + competition: s.consecutiveFailures?.competition ?? 0, + }, + nextRunAfter: { + tenero: s.nextRunAfter?.tenero ?? null, + competition: s.nextRunAfter?.competition ?? null, + }, nextAlarmAt, }; } async refreshNow(task: SchedulerTask): Promise { const logger = this.makeLogger({ trigger: "refreshNow", task }); - const out: { tenero?: TeneroRunResult } = {}; + const out: SchedulerRefreshResult = {}; if (task === "tenero" || task === "all") { out.tenero = await this.runTenero(logger); } + if (task === "competition" || task === "all") { + out.competition = await this.runCompetition(logger); + } return out; } @@ -119,9 +141,8 @@ 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: + // TODO(#768 follow-up): once balance snapshots land, 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. @@ -147,16 +168,38 @@ export class SchedulerDO extends DurableObject { nextRunAfter: teneroNextRunAfter || null, }); } + + const competitionNextRunAfter = stored.nextRunAfter?.competition ?? 0; + const competitionDue = + competitionNextRunAfter <= tickStartedAt && + (stored.lastCompetitionRunAt ?? 0) + COMPETITION_INTERVAL_MS <= + tickStartedAt + 1_000; + + if (competitionDue) { + try { + await this.runCompetition(logger); + } catch (error) { + logger.error("scheduler.competition_unexpected_error", { + error: String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + await this.bumpFailures("competition"); + await this.deferTask("competition", COMPETITION_INTERVAL_MS); + } + } else { + logger.debug("scheduler.competition_not_due", { + lastRunAt: stored.lastCompetitionRunAt ?? null, + nextRunAfter: competitionNextRunAfter || null, + }); + } } finally { await this.ctx.storage.setAlarm(Date.now() + ALARM_TICK_MS); } } // 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. + // bounded slice of the tick (e.g. AbortSignal.timeout(30_000) per task) + // so a slow Hiro response can't starve other scheduler work. // // The orchestration body is `runTeneroTask` in // `lib/scheduler/tenero-task.ts` — kept testable without a DO harness. @@ -178,6 +221,20 @@ export class SchedulerDO extends DurableObject { return result; } + private async runCompetition(parentLogger: Logger): Promise { + const logger = parentLogger.child + ? parentLogger.child({ task: "competition" }) + : parentLogger; + + const result = await runCompetitionScheduler( + { DB: this.env.DB, HIRO_API_KEY: this.env.HIRO_API_KEY }, + logger + ); + + await this.persistCompetitionResult(result); + return result; + } + /** * Read the DO's bookkeeping in a single parallel batch of targeted gets. * `storage.list({ prefix: "" })` scans every stored key — fine at today's @@ -191,19 +248,29 @@ export class SchedulerDO extends DurableObject { const [ lastTeneroRunAt, lastTeneroResult, + lastCompetitionRunAt, + lastCompetitionResult, 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("lastCompetitionRunAt"), + this.ctx.storage.get("lastCompetitionResult"), + this.ctx.storage.get>>( + "consecutiveFailures" + ), this.ctx.storage.get("pausedUntil"), - this.ctx.storage.get<{ tenero?: number }>("nextRunAfter"), + this.ctx.storage.get>>( + "nextRunAfter" + ), ]); return { ...(typeof lastTeneroRunAt === "number" ? { lastTeneroRunAt } : {}), ...(lastTeneroResult ? { lastTeneroResult } : {}), + ...(typeof lastCompetitionRunAt === "number" ? { lastCompetitionRunAt } : {}), + ...(lastCompetitionResult ? { lastCompetitionResult } : {}), ...(consecutiveFailures ? { consecutiveFailures } : {}), ...(typeof pausedUntil === "number" ? { pausedUntil } : {}), ...(nextRunAfter ? { nextRunAfter } : {}), @@ -219,9 +286,7 @@ export class SchedulerDO extends DurableObject { 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 }; + const nextRunAfter = await this.readNextRunAfter(); if (nextRunAfter.tenero) { delete nextRunAfter.tenero; await this.ctx.storage.put("nextRunAfter", nextRunAfter); @@ -231,32 +296,64 @@ export class SchedulerDO extends DurableObject { } if (opts.rateLimited) { - const nextRunAfter = ((await this.ctx.storage.get<{ tenero?: number }>( - "nextRunAfter" - )) ?? {}) as { tenero?: number }; + const nextRunAfter = await this.readNextRunAfter(); nextRunAfter.tenero = Date.now() + (opts.rateLimitBackoffMs ?? TENERO_MINUTE_QUOTA_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 }; + private async persistCompetitionResult( + result: CompetitionSchedulerSummary + ): Promise { + await this.ctx.storage.put("lastCompetitionRunAt", Date.now()); + await this.ctx.storage.put("lastCompetitionResult", result); + await this.clearFailures("competition"); + const nextRunAfter = await this.readNextRunAfter(); + if (nextRunAfter.competition) { + delete nextRunAfter.competition; + await this.ctx.storage.put("nextRunAfter", nextRunAfter); + } + } + + private async bumpFailures(task: SchedulerFailureTask): Promise { + const cur = + ((await this.ctx.storage.get>>( + "consecutiveFailures" + )) ?? {}) as Partial>; 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; + private async clearFailures(task: SchedulerFailureTask): Promise { + const cur = + ((await this.ctx.storage.get>>( + "consecutiveFailures" + )) ?? {}) as Partial>; + if ((cur[task] ?? 0) === 0) return; cur[task] = 0; await this.ctx.storage.put("consecutiveFailures", cur); } + private async deferTask( + task: SchedulerFailureTask, + delayMs: number + ): Promise { + const nextRunAfter = await this.readNextRunAfter(); + nextRunAfter[task] = Date.now() + delayMs; + await this.ctx.storage.put("nextRunAfter", nextRunAfter); + } + + private async readNextRunAfter(): Promise< + Partial> + > { + return ( + (await this.ctx.storage.get>>( + "nextRunAfter" + )) ?? {} + ); + } + private makeLogger(extra: Record): Logger { const ctxBase = { path: "/__do/scheduler", diff --git a/wrangler.jsonc b/wrangler.jsonc index 86a8398b..75e25c74 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -83,9 +83,10 @@ }, // 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. + // Current scope: Tenero price refresh (every ~5 min) writing to + // VERIFIED_AGENTS KV under `tenero:price:{tokenId}`, plus the trading + // competition Hiro catch-up sweep (every ~15 min). Balance snapshots + // land in a follow-up PR. 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.