Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
138 changes: 138 additions & 0 deletions app/api/bounties/[id]/submit/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/lib/bounty")>();
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");
});
});
19 changes: 18 additions & 1 deletion app/api/bounties/[id]/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -17,6 +18,7 @@ import {
buildSubmitMessage,
generateSubmissionId,
getBounty,
hasSubmission,
insertSubmission,
isWithinSignatureWindow,
validateSubmit,
Expand Down Expand Up @@ -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 = {
Expand Down
4 changes: 3 additions & 1 deletion app/api/openapi.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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" },
},
},
Expand Down
5 changes: 5 additions & 0 deletions app/docs/[topic]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

\`\`\`
Expand Down
Loading