Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
ae6f183
feat(competition): add D1 read helpers for trading-comp surface
biwasxyz May 11, 2026
92a6039
test(competition): unit tests for d1-reads helpers
biwasxyz May 11, 2026
3538335
feat(competition): add GET /api/competition/status
biwasxyz May 11, 2026
9ee6235
feat(competition): add GET /api/competition/trades + POST 501 stub
biwasxyz May 11, 2026
64e6f0e
test(competition): D1-throws fallback regression for read routes
biwasxyz May 11, 2026
42a682c
docs(openapi): publish /api/competition/{status,trades} schemas
biwasxyz May 11, 2026
76c67f1
docs(agent-card): advertise trading-comp skill in A2A agent card
biwasxyz May 11, 2026
e246baf
docs(llms): list /api/competition/* routes in quick-start guide
biwasxyz May 11, 2026
a826245
docs(llms-full): full reference for trading-comp surface
biwasxyz May 11, 2026
cb08899
feat(competition): add Bitflow allowlist for trading-comp verifier
biwasxyz May 11, 2026
6f4fd5d
feat(competition): add swap event parser
biwasxyz May 11, 2026
77f9568
feat(competition): add single-tx verifier with INSERT OR IGNORE persist
biwasxyz May 11, 2026
a934b83
feat(competition): wire POST /api/competition/trades verifier
biwasxyz May 11, 2026
0cc406e
test(competition): drop stale 501-stub assertion from fallback test
biwasxyz May 11, 2026
225a978
test(competition): unit tests for swap event parser
biwasxyz May 11, 2026
0ead7f1
test(competition): unit tests for verifyAndPersistSwap
biwasxyz May 11, 2026
dec3180
test(competition): route-level tests for POST /api/competition/trades
biwasxyz May 11, 2026
ffddd4b
feat(competition): chainhook payload validation + HMAC auth
biwasxyz May 11, 2026
c06f29b
feat(competition): chainhook ingestion route
biwasxyz May 11, 2026
cd7f158
test(competition): unit tests for chainhook helpers
biwasxyz May 11, 2026
58c5b39
test(competition): route-level tests for POST /api/competition/chainhook
biwasxyz May 11, 2026
79c6190
feat(competition): nightly catch-up cron sweep helper
biwasxyz May 11, 2026
ddffffa
feat(competition): cron catch-up route
biwasxyz May 11, 2026
d041df3
test(competition): unit tests for runCompetitionCron
biwasxyz May 11, 2026
e64ad63
test(competition): route-level tests for POST /api/competition/cron
biwasxyz May 11, 2026
f3a14ed
docs(openapi): publish live POST verifier + chainhook + cron schemas
biwasxyz May 11, 2026
9b93bea
docs(llms): list live verifier + chainhook + cron routes
biwasxyz May 11, 2026
b3bc850
docs(llms-full): live POST verifier section + chainhook + cron blocks
biwasxyz May 11, 2026
07c7d21
docs(agent-card): expand trading-comp skill with verifier description
biwasxyz May 11, 2026
fbe238d
revert(competition): remove chainhook receiver route
biwasxyz May 11, 2026
54d2900
revert(competition): remove chainhook payload + HMAC helpers
biwasxyz May 11, 2026
4d2cd41
revert(competition): remove chainhook tests
biwasxyz May 11, 2026
f46311f
revert(openapi): drop /api/competition/chainhook path
biwasxyz May 11, 2026
5e85b19
revert(llms): drop chainhook route + retune cron cadence in quick-start
biwasxyz May 11, 2026
71347e9
revert(llms-full): drop chainhook section + retune cron to 15-min
biwasxyz May 11, 2026
bad0dd1
revert(agent-card): replace chainhook ingestion claim with 'reserved …
biwasxyz May 11, 2026
4b3e072
chore(competition): retune cron docstring for 15-min cadence
biwasxyz May 11, 2026
48c4385
chore(competition): cron route self-doc reflects 15-min cadence
biwasxyz May 11, 2026
65f2c34
chore(openapi): cron summary + description reflect 15-min cadence
biwasxyz May 11, 2026
9d354f3
fix(competition): recognize Hiro 'stx_asset' event_type for native STX
biwasxyz May 11, 2026
2cde748
test(competition): align parse fixtures with real Hiro 'stx_asset' st…
biwasxyz May 11, 2026
5f43f6f
test(competition): align verify fixture with real Hiro 'stx_asset' st…
biwasxyz May 11, 2026
5e02a8d
docs(competition): tighten self-doc wording on agent_id field
biwasxyz May 11, 2026
4a91a80
fix(competition): drop pending-cache short-circuit on POST /trades
biwasxyz May 11, 2026
45b3c19
test(competition): update POST tests for dropped pending-cache short-…
biwasxyz May 11, 2026
16db030
fix(competition): readSwap before Hiro fetch in verifier
biwasxyz May 11, 2026
64cb39d
fix(competition): 409 on duplicate txid + drop KV pending machinery
biwasxyz May 11, 2026
067a12d
test(competition): 409 path + lifecycle sequence + drop KV-tracker tests
biwasxyz May 11, 2026
8ca8727
docs(openapi): document 409 txid_already_verified + simplified 202
biwasxyz May 11, 2026
a585167
docs(llms-full): document 409 txid_already_verified + MCP pre-check
biwasxyz May 11, 2026
06b0a70
fix(competition): success-only gate per @whoabuddy's comp spec
biwasxyz May 11, 2026
a10f8c4
test(competition): success-only gate regression coverage
biwasxyz May 11, 2026
48e340d
feat(competition): GET /api/competition/allowlist — discoverable veri…
biwasxyz May 11, 2026
0f3a94f
feat(competition): comp-start gate — reject trades before 2026-05-13T…
biwasxyz May 12, 2026
85199a8
refactor(competition): move cron cursor from KV to D1 (competition_st…
biwasxyz May 12, 2026
9afa89d
refactor(competition): run catch-up via SchedulerDO
whoabuddy May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/.well-known/agent.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,30 @@ 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 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 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: [
"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"],
outputModes: ["application/json"],
},
{
id: "health-check",
name: "System Health Check",
Expand Down
28 changes: 21 additions & 7 deletions app/api/admin/scheduler/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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`");
Expand All @@ -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");
Expand All @@ -102,15 +105,26 @@ 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");
expect(schedulerStub.refreshNow).toHaveBeenCalledWith("all");
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");
});
});
4 changes: 2 additions & 2 deletions app/api/admin/scheduler/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SchedulerTask>(["tenero", "all"]);
const ALLOWED_TASKS = new Set<SchedulerTask>(["tenero", "competition", "all"]);

function json(body: unknown, init: ResponseInit = {}) {
const headers = new Headers(init.headers);
Expand Down Expand Up @@ -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 }
);
}
Expand Down
219 changes: 219 additions & 0 deletions app/api/competition/__tests__/d1-throws-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* 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 } 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()) as any;
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()) as any;
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");
});
});

// 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 () => {
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()) as any;
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()) as any;
expect(body.endpoint).toBe("/api/competition/trades");
});
});
Loading
Loading