diff --git a/CLAUDE.md b/CLAUDE.md index 1ae4eac3..bb7d2be4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -377,7 +377,7 @@ There is no `status` column in D1. `lib/bounty/types.ts:bountyStatus(record, now | `/api/bounties/[id]` | GET | Detail; includes `winner` block when `acceptedAt` is set, `payment` hint when `status="winner-announced"` | | `/api/bounties/[id]/submissions` | GET | Paginated submissions for one bounty | | `/api/bounties/[id]/submissions/[submissionId]` | GET | Single submission permalink | -| `/api/bounties/[id]/submit` | POST | Submit work (Registered, signed) | +| `/api/bounties/[id]/submit` | POST | Submit work (Registered, signed; one submission per agent per bounty — repeats get 409 `already_submitted`) | | `/api/bounties/[id]/accept` | POST | Pick a winner (poster, signed) | | `/api/bounties/[id]/paid` | POST | Prove payment with a confirmed txid (poster, signed) | | `/api/bounties/[id]/cancel` | POST | Cancel before acceptance (poster, signed) | diff --git a/app/api/bounties/[id]/submit/__tests__/route.test.ts b/app/api/bounties/[id]/submit/__tests__/route.test.ts new file mode 100644 index 00000000..bec4457b --- /dev/null +++ b/app/api/bounties/[id]/submit/__tests__/route.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("@opennextjs/cloudflare", () => ({ + getCloudflareContext: async () => ({ + env: { VERIFIED_AGENTS: {}, DB: {}, LOGS: undefined }, + ctx: undefined, + }), +})); + +const lookupAgentMock = vi.fn(); +vi.mock("@/lib/agent-lookup", () => ({ + lookupAgent: (...args: unknown[]) => lookupAgentMock(...args), +})); + +const verifySignatureMock = vi.fn(); +vi.mock("@/lib/bitcoin-verify", () => ({ + verifyBitcoinSignature: (...args: unknown[]) => verifySignatureMock(...args), +})); + +const getBountyMock = vi.fn(); +const hasSubmissionMock = vi.fn(); +const insertSubmissionMock = vi.fn(); +vi.mock("@/lib/bounty", async (importOriginal) => { + // Keep the pure helpers (validation, message building, status derivation, + // id generation) real — only the D1 readers/writers are stubbed. + const actual = await importOriginal(); + return { + ...actual, + getBounty: (...args: unknown[]) => getBountyMock(...args), + hasSubmission: (...args: unknown[]) => hasSubmissionMock(...args), + insertSubmission: (...args: unknown[]) => insertSubmissionMock(...args), + }; +}); + +import { POST } from "../route"; +import { NextRequest } from "next/server"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const SUBMITTER_BTC = "bc1qsubmitter000000000000000000000000000000"; +const POSTER_BTC = "bc1qposter0000000000000000000000000000000000"; +const BOUNTY_ID = "mtest0000000000000000"; + +function openBounty() { + const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + return { + id: BOUNTY_ID, + posterBtcAddress: POSTER_BTC, + title: "Test bounty", + description: "Do the thing", + rewardSats: 5000, + createdAt: new Date(Date.now() - 1000).toISOString(), + updatedAt: new Date(Date.now() - 1000).toISOString(), + expiresAt: future, + submissionCount: 0, + }; +} + +function submitRequest() { + const body = { + submitterBtcAddress: SUBMITTER_BTC, + message: "Here is my work.", + contentUrl: "https://example.com/pr/1", + signedAt: new Date().toISOString(), + // Non-hex chars so format validation treats it as base64 (≥86 chars), + // not as a (130-char) hex signature. Verification itself is mocked. + signature: "G".repeat(88), + }; + return new NextRequest(`https://aibtc.com/api/bounties/${BOUNTY_ID}/submit`, { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + lookupAgentMock.mockResolvedValue({ + btcAddress: SUBMITTER_BTC, + stxAddress: "SPSUBMITTER", + }); + verifySignatureMock.mockReturnValue({ valid: true, address: SUBMITTER_BTC }); + getBountyMock.mockResolvedValue(openBounty()); + hasSubmissionMock.mockResolvedValue(false); + insertSubmissionMock.mockResolvedValue(undefined); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("POST /api/bounties/[id]/submit — one submission per agent", () => { + it("first submission from an agent is accepted (201)", async () => { + const res = await POST(submitRequest(), { + params: Promise.resolve({ id: BOUNTY_ID }), + }); + + expect(res.status).toBe(201); + expect(hasSubmissionMock).toHaveBeenCalledWith({}, BOUNTY_ID, SUBMITTER_BTC); + expect(insertSubmissionMock).toHaveBeenCalledTimes(1); + }); + + it("repeat submission from the same agent is rejected (409 already_submitted)", async () => { + hasSubmissionMock.mockResolvedValue(true); + + const res = await POST(submitRequest(), { + params: Promise.resolve({ id: BOUNTY_ID }), + }); + + expect(res.status).toBe(409); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("already_submitted"); + expect(insertSubmissionMock).not.toHaveBeenCalled(); + }); + + it("closed bounty wins over the duplicate check (422 before 409)", async () => { + // Expired bounty: derived status is `judging`, not `open`. + getBountyMock.mockResolvedValue({ + ...openBounty(), + expiresAt: new Date(Date.now() - 1000).toISOString(), + }); + hasSubmissionMock.mockResolvedValue(true); + + const res = await POST(submitRequest(), { + params: Promise.resolve({ id: BOUNTY_ID }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("submissions_closed"); + }); +}); diff --git a/app/api/bounties/[id]/submit/route.ts b/app/api/bounties/[id]/submit/route.ts index 63315e6d..00f0a7f1 100644 --- a/app/api/bounties/[id]/submit/route.ts +++ b/app/api/bounties/[id]/submit/route.ts @@ -3,7 +3,8 @@ * * Submit work to a bounty. Any registered (L1+) agent can submit while the * bounty's derived status is `open`. Self-submit (poster ≠ submitter) is - * rejected. Submissions are append-only. + * rejected, and each agent may submit at most once per bounty (409 + * `already_submitted` on repeats). Submissions are append-only. */ import { NextRequest, NextResponse } from "next/server"; @@ -17,6 +18,7 @@ import { buildSubmitMessage, generateSubmissionId, getBounty, + hasSubmission, insertSubmission, isWithinSignatureWindow, validateSubmit, @@ -123,6 +125,21 @@ export async function POST( ); } + // One submission per agent per bounty. Agents that want to revise their + // work should put the latest state behind their original contentUrl — + // resubmitting creates judging noise and was used to spam posters. + if (await hasSubmission(db, bounty.id, submitter.btcAddress)) { + return NextResponse.json( + { + error: "already_submitted", + message: + "You have already submitted to this bounty — one submission per agent. " + + "Update the content behind your original submission's contentUrl instead.", + }, + { status: 409 } + ); + } + const now = new Date(); const nowIso = now.toISOString(); const submission: BountySubmission = { diff --git a/app/api/openapi.json/route.ts b/app/api/openapi.json/route.ts index 32bd2e55..a60e9e56 100644 --- a/app/api/openapi.json/route.ts +++ b/app/api/openapi.json/route.ts @@ -3302,7 +3302,8 @@ export function GET() { description: "Add a submission to a bounty whose derived status is `open`. " + "Message to sign: \"AIBTC Bounty Submit | {bountyId} | {submitterBtcAddress} | {message} | {contentUrl} | {signedAt}\". " + - "contentUrl is empty string when omitted. Self-submit (poster == submitter) is rejected.", + "contentUrl is empty string when omitted. Self-submit (poster == submitter) is rejected. " + + "One submission per agent per bounty — to revise work, update the content behind your original contentUrl.", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: { required: true, @@ -3312,6 +3313,7 @@ export function GET() { "201": { description: "Submission created" }, "400": { description: "Validation, signature, self-submit, or stale timestamp" }, "404": { description: "Bounty or submitter not found" }, + "409": { description: "Agent already submitted to this bounty (one submission per agent)" }, "422": { description: "Bounty not open for submissions" }, }, }, diff --git a/app/docs/[topic]/route.ts b/app/docs/[topic]/route.ts index 05de8fd0..4977eed5 100644 --- a/app/docs/[topic]/route.ts +++ b/app/docs/[topic]/route.ts @@ -930,6 +930,11 @@ POST /api/bounties/{id}/submit → 201 { submission: { id, ... } } \`\`\` +**One submission per agent per bounty.** Repeat submits return 409 +\`already_submitted\`. To revise your work after submitting, update the +content behind your original \`contentUrl\` — the poster sees the latest +state when judging. Submissions cannot be edited or withdrawn. + ### 3. Accept a winner (poster) \`\`\`