diff --git a/app/.well-known/agent.json/route.ts b/app/.well-known/agent.json/route.ts index a3bb72bb..5ddb88a5 100644 --- a/app/.well-known/agent.json/route.ts +++ b/app/.well-known/agent.json/route.ts @@ -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", 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__/d1-throws-fallback.test.ts b/app/api/competition/__tests__/d1-throws-fallback.test.ts new file mode 100644 index 00000000..9e396bb3 --- /dev/null +++ b/app/api/competition/__tests__/d1-throws-fallback.test.ts @@ -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"); + }); +}); 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..bdeb1eef --- /dev/null +++ b/app/api/competition/__tests__/post-verifier.test.ts @@ -0,0 +1,330 @@ +/** + * 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 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) + * - 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 + * - 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 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: "pending" }); + const res = await POST(buildRequest({ txid: TXID })); + expect(res.status).toBe(202); + const body = (await res.json()) as any; + expect(body.accepted).toBe(true); + expect(body.note).toMatch(/propagated/i); + }); + + 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(409); + const body = (await res.json()) as any; + expect(body).toMatchObject({ + code: "txid_already_verified", + retryable: false, + }); + expect(body.existing_row).toEqual(ROW); + }); + + it("includes the row.source in existing_row so callers know which ingestion path won", async () => { + mockEnv(); + (verifyAndPersistSwap as Mock).mockResolvedValue({ + status: "verified", + inserted: false, + row: ROW, + }); + const res = await POST(buildRequest({ txid: TXID })); + 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"); + }); + + 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()) as any; + 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()) as any; + 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); + }); +}); + +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()) as any; + 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()) as any; + 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()) as any; + 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"); + }); +}); 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", + }, + } + ); +} diff --git a/app/api/competition/status/route.ts b/app/api/competition/status/route.ts new file mode 100644 index 00000000..07252f50 --- /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 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')", + 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" } } + ); + } +} diff --git a/app/api/competition/trades/route.ts b/app/api/competition/trades/route.ts new file mode 100644 index 00000000..fecdac58 --- /dev/null +++ b/app/api/competition/trades/route.ts @@ -0,0 +1,373 @@ +// CACHE_INVARIANTS:POSTURE=public-only-get +// See lib/inbox/CACHE_INVARIANTS.md — GET handler is fully public. +// POST is the agent-submit verifier (Phase 3.1 PR-B). + +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, + encodeSwapsCursor, + decodeSwapsCursor, +} from "@/lib/competition/d1-reads"; +import { verifyAndPersistSwap } from "@/lib/competition/verify"; + +const TXID_RE = /^(0x)?[0-9a-fA-F]{64}$/; + +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.", + 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'; 'cron' is SchedulerDO catch-up)", + scored_value: "number | null", + scored_at: "string | null (ISO-8601)", + }, + ], + next_cursor: "string | null (pass back as ?cursor= for the next page)", + }, + }, + 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 / 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", + "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", + "503": "D1 temporarily unavailable — retryable", + }, + rateLimit: "20/min per IP (RATE_LIMIT_MUTATING)", + }, + 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" } } + ); +} + +/** + * 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 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 + */ +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" } } + ); + } + + 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") { + 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") { + // 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 / scheduler / + // 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 }); + } + + // result.status === "rejected" + switch (result.code) { + case "sender_not_registered": + case "contract_not_allowlisted": + case "tx_failed": + case "before_comp_start": + 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 } + ); + } + } +} diff --git a/app/api/openapi.json/route.ts b/app/api/openapi.json/route.ts index 99c34370..f381a520 100644 --- a/app/api/openapi.json/route.ts +++ b/app/api/openapi.json/route.ts @@ -1032,6 +1032,288 @@ 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"], + description: "`cron` is the legacy schema label for SchedulerDO catch-up.", + }, + 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 confirmed swap txid for verification", + description: + "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 / 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, + content: { + "application/json": { + schema: { + type: "object", + required: ["txid"], + properties: { + txid: { + type: "string", + description: "Stacks tx hash, 64 hex chars (0x-prefix accepted).", + pattern: "^(0x)?[0-9a-fA-F]{64}$", + }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "First-time verified — body is the persisted SwapRow", + content: { "application/json": { schema: { type: "object" } } }, + }, + "202": { + description: + "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": { + 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" } } }, + }, + "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 }`.", + 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/levels": { get: { operationId: "getLevelSystem", diff --git a/app/llms-full.txt/route.ts b/app/llms-full.txt/route.ts index 9e54623a..d09e5a03 100644 --- a/app/llms-full.txt/route.ts +++ b/app/llms-full.txt/route.ts @@ -866,6 +866,134 @@ 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. 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). Two ingestion +paths converge on the same row via INSERT OR IGNORE on \`txid\`: agent-submit +(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 +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 + +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" \\ + -d '{"txid":"0x46bc5587ae56e5bd4453daa2bf63c2a9e0414953fd21a82eb44f2f926f0ee0e4"}' +\`\`\` + +Response matrix: + +- \`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 + 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. +- \`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. +- \`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. + +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). + +### Scheduler Catch-Up + +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; \`'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 + + 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. diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index cacd3a39..384fbb2f 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 + +- 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) + ### 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. + ### System - [Health Check](https://aibtc.com/api/health): GET system status and KV connectivity 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(); + }); +}); diff --git a/lib/competition/__tests__/parse.test.ts b/lib/competition/__tests__/parse.test.ts new file mode 100644 index 00000000..aaa9cae0 --- /dev/null +++ b/lib/competition/__tests__/parse.test.ts @@ -0,0 +1,325 @@ +/** + * 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_asset", + 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_asset", + 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_asset", + 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_asset", + 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_asset", + 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_asset", + 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_asset", + 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 — 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_asset", + 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); + }); +}); diff --git a/lib/competition/__tests__/scheduler.test.ts b/lib/competition/__tests__/scheduler.test.ts new file mode 100644 index 00000000..4ad7f235 --- /dev/null +++ b/lib/competition/__tests__/scheduler.test.ts @@ -0,0 +1,285 @@ +/** + * Tests for lib/competition/scheduler.ts + * + * Phase 3.1 PR-D — exercises the scheduler 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 { + runCompetitionScheduler, + COMPETITION_SCHEDULER_MAX_ADDRESSES_PER_RUN, +} from "../scheduler"; +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"; + +/** + * 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>(), + }; + + const prepare = vi.fn((sql: string) => { + 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 })), + }), + }), + }; + }); + + const db = { prepare } as unknown as D1Database; + return { db, cursorOps }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +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({ + 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 runCompetitionScheduler( + { DB: db, 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 { 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 runCompetitionScheduler( + { DB: db }, + undefined, + { fetchAddressTxsImpl } + ); + + expect(summary.found).toBe(0); + expect(verifyAndPersistSwap).not.toHaveBeenCalled(); + }); + + it("skips non-contract_call txs without dispatch", async () => { + const { db } = makeDb(["SP_ADDR_001"]); + const fetchAddressTxsImpl = vi.fn().mockResolvedValue([ + { tx_id: "0xaaa", tx_type: "token_transfer" }, + ]); + + const summary = await runCompetitionScheduler( + { DB: db }, + undefined, + { fetchAddressTxsImpl } + ); + expect(summary.found).toBe(0); + expect(verifyAndPersistSwap).not.toHaveBeenCalled(); + }); + + it("tallies inserted vs alreadyKnown vs pending vs rejected", async () => { + 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 runCompetitionScheduler( + { DB: db }, + undefined, + { fetchAddressTxsImpl } + ); + + expect(summary).toMatchObject({ + scanned: 1, + found: 4, + inserted: 1, + alreadyKnown: 1, + pending: 1, + rejected: 1, + }); + }); +}); + +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: 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 runCompetitionScheduler( + { DB: db }, + undefined, + { fetchAddressTxsImpl } + ); + + expect(summary.cursor).toBe(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 { db, cursorOps } = makeDb( + ["SP_ADDR_001", "SP_ADDR_002"], + { initialCursor: "SP_PRIOR_CURSOR" } + ); + const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); + + const summary = await runCompetitionScheduler( + { DB: db }, + undefined, + { fetchAddressTxsImpl } + ); + + expect(summary.cursor).toBeNull(); + expect(cursorOps.clear).toHaveBeenCalled(); + expect(cursorOps.set).not.toHaveBeenCalled(); + }); + + it("uses the cursor query branch when a cursor is present", async () => { + const { db } = makeDb([], { initialCursor: "SP_LAST_RUN" }); + const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); + + await runCompetitionScheduler( + { DB: db }, + undefined, + { fetchAddressTxsImpl } + ); + + 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 { db } = makeDb([]); + const fetchAddressTxsImpl = vi.fn().mockResolvedValue([]); + + await runCompetitionScheduler( + { DB: db }, + undefined, + { fetchAddressTxsImpl } + ); + + 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("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")); + + 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 runCompetitionScheduler( + { DB: db }, + undefined, + { fetchAddressTxsImpl } + ); + + expect(summary.rejected).toBe(1); + expect(summary.inserted).toBe(0); + }); +}); diff --git a/lib/competition/__tests__/verify.test.ts b/lib/competition/__tests__/verify.test.ts new file mode 100644 index 00000000..152d3409 --- /dev/null +++ b/lib/competition/__tests__/verify.test.ts @@ -0,0 +1,442 @@ +/** + * 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 { COMP_START_TIMESTAMP } from "../constants"; +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"; + +// 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, + headers: { "content-type": "application/json" }, + }); +} + +function buildHappyTx() { + return { + tx_id: TXID, + tx_status: "success", + sender_address: AGENT, + tx_type: "contract_call", + burn_block_time: POST_START_BURN_TIME, + 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_asset", + 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 — 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 — 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())); + 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"); + }); +}); 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", +]); diff --git a/lib/competition/constants.ts b/lib/competition/constants.ts new file mode 100644 index 00000000..8a3a37a5 --- /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 scheduler 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/d1-reads.ts b/lib/competition/d1-reads.ts new file mode 100644 index 00000000..3590e0a5 --- /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; scheduler catch-up + allowlist in 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; +} diff --git a/lib/competition/parse.ts b/lib/competition/parse.ts new file mode 100644 index 00000000..0ad5c9ad --- /dev/null +++ b/lib/competition/parse.ts @@ -0,0 +1,227 @@ +/** + * 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"; + +/** + * 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; + 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 = + STX_EVENT_TYPES.has(ev.event_type ?? "") + ? 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), + }, + }; +} diff --git a/lib/competition/scheduler.ts b/lib/competition/scheduler.ts new file mode 100644 index 00000000..3f34b046 --- /dev/null +++ b/lib/competition/scheduler.ts @@ -0,0 +1,190 @@ +/** + * 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 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. + * + * 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 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. + * + * Returns a structured summary for the logs: + * { scanned, found, inserted, alreadyKnown, rejected, pending, cursor } + * + * SchedulerDO owns cadence and manual refresh. No public operator endpoint + * or shared-secret route is required. + */ + +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"; +import { + clearCompetitionSchedulerCursor, + getCompetitionSchedulerCursor, + setCompetitionSchedulerCursor, +} from "./state"; + +/** 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 CompetitionSchedulerSummary { + 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.scheduler.hiro_non_ok", { + stxAddress, + status: response.status, + }); + return []; + } + const body = (await response.json()) as { results?: AddressTxEntry[] }; + return body.results ?? []; + } catch (err) { + logger?.warn?.("competition.scheduler.hiro_threw", { + stxAddress, + error: String(err), + }); + return []; + } +} + +/** + * Page through registered_wallets starting from the cursor. Returns up to + * 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. + */ +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, 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 === COMPETITION_SCHEDULER_MAX_ADDRESSES_PER_RUN) { + nextCursor = rows[rows.length - 1].stx_address; + } + return { rows, nextCursor }; +} + +export interface RunCompetitionSchedulerOptions { + /** Inject a custom address-history fetcher (for tests). */ + fetchAddressTxsImpl?: typeof fetchAddressTxs; +} + +/** + * 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 D1 cursor lets the sweep resume across runs + * rather than always starting at the head. + */ +export async function runCompetitionScheduler( + env: { DB: D1Database; HIRO_API_KEY?: string }, + logger?: Logger, + options: RunCompetitionSchedulerOptions = {} +): Promise { + const txsFetcher = options.fetchAddressTxsImpl ?? fetchAddressTxs; + + const cursor = await getCompetitionSchedulerCursor(env.DB); + const { rows, nextCursor } = await fetchAddressPage(env.DB, cursor); + + const summary: CompetitionSchedulerSummary = { + 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.scheduler.verify_threw", { + stxAddress: stx_address, + txid: tx.tx_id, + error: String(err), + }); + } + } + } + + // 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 setCompetitionSchedulerCursor(env.DB, nextCursor); + } else { + await clearCompetitionSchedulerCursor(env.DB); + } + + logger?.info?.("competition.scheduler.summary", { ...summary }); + return summary; +} diff --git a/lib/competition/state.ts b/lib/competition/state.ts new file mode 100644 index 00000000..34690e3a --- /dev/null +++ b/lib/competition/state.ts @@ -0,0 +1,45 @@ +/** + * D1-backed persistent state for the competition scheduler. + * + * 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 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 all read the same row. + */ + +const COMPETITION_SCHEDULER_CURSOR_KEY = "competition_scheduler_cursor"; + +export async function getCompetitionSchedulerCursor(db: D1Database): Promise { + const row = await db + .prepare(`SELECT value FROM competition_state WHERE key = ?1`) + .bind(COMPETITION_SCHEDULER_CURSOR_KEY) + .first<{ value: string }>(); + return row?.value ?? null; +} + +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(COMPETITION_SCHEDULER_CURSOR_KEY, cursor) + .run(); +} + +export async function clearCompetitionSchedulerCursor(db: D1Database): Promise { + await db + .prepare(`DELETE FROM competition_state WHERE key = ?1`) + .bind(COMPETITION_SCHEDULER_CURSOR_KEY) + .run(); +} diff --git a/lib/competition/verify.ts b/lib/competition/verify.ts new file mode 100644 index 00000000..e8380c84 --- /dev/null +++ b/lib/competition/verify.ts @@ -0,0 +1,409 @@ +/** + * Single-tx verifier for the trading-comp surface. + * + * `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. 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. + * - 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 { COMP_START_TIMESTAMP } from "./constants"; +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" + | "before_comp_start" + | "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 scheduler 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 { + // 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); + } 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 }; + } + + 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}'`, + }; + } + + // 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.`, + }; + } + + // 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 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) { + 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) { + 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`, + }; + } + + 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, + }, + }; +} 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 new file mode 100644 index 00000000..2ca92d7b --- /dev/null +++ b/migrations/009_competition_state.sql @@ -0,0 +1,16 @@ +-- Migration 009: competition_state table. +-- 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 scheduler 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()) +); 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.