diff --git a/app/api/bounties/[id]/accept/route.ts b/app/api/bounties/[id]/accept/route.ts index e2a4d147..e3eb98d7 100644 --- a/app/api/bounties/[id]/accept/route.ts +++ b/app/api/bounties/[id]/accept/route.ts @@ -1,8 +1,13 @@ /** * POST /api/bounties/[id]/accept * - * Poster picks a winning submission. Allowed when the bounty's derived - * status is `open` (accepting early) or `judging` (submission window closed). + * Poster picks a winning submission. Allowed when the bounty's derived status + * is `open` (accepting early), `judging` (window closed, no winners yet), or + * `partially-filled` (some slots taken, more remain — multi-winner only). + * + * For multi-winner bounties, this endpoint may be called up to `maxWinners` + * times (once per winner). Each call requires a fresh signature over the chosen + * `submissionId`. */ import { NextRequest, NextResponse } from "next/server"; @@ -16,7 +21,7 @@ import { getBounty, getSubmission, isWithinSignatureWindow, - setAccepted, + insertWinner, validateAccept, } from "@/lib/bounty"; @@ -82,14 +87,16 @@ export async function POST( ); } - // Status guard + // Status guard — accepting is allowed from open, judging, or partially-filled const status = bountyStatus(bounty); - if (status !== "open" && status !== "judging") { + if (status !== "open" && status !== "judging" && status !== "partially-filled") { return NextResponse.json( { error: "invalid_state", - message: `Cannot accept in status "${status}". A winner must be picked while open or judging.`, + message: `Cannot accept in status "${status}". Acceptance requires open, judging, or partially-filled status.`, status, + winnerCount: bounty.winnerCount, + maxWinners: bounty.maxWinners, }, { status: 422 } ); @@ -105,13 +112,24 @@ export async function POST( } const acceptedAt = new Date().toISOString(); - const ok = await setAccepted(db, bounty.id, submission.id, acceptedAt); - if (!ok) { - // Raced with another concurrent accept / cancel / paid. + const result = await insertWinner(db, bounty.id, submission.id, acceptedAt); + if (result === "duplicate") { + return NextResponse.json( + { + error: "already_a_winner", + message: "This submission has already been accepted as a winner.", + }, + { status: 409 } + ); + } + if (result === "conflict") { + // All slots filled or bounty state changed concurrently. return NextResponse.json( { error: "conflict", - message: "Bounty state changed concurrently. Re-fetch the bounty and retry if appropriate.", + message: "No winner slots available. The bounty may be full or its state changed concurrently.", + winnerCount: bounty.winnerCount, + maxWinners: bounty.maxWinners, }, { status: 409 } ); @@ -121,6 +139,8 @@ export async function POST( bountyId: bounty.id, submissionId: submission.id, winner: submission.submitterBtcAddress, + winnerCount: bounty.winnerCount + 1, + maxWinners: bounty.maxWinners, }); const fresh = await getBounty(db, bounty.id); diff --git a/app/api/bounties/[id]/paid/route.ts b/app/api/bounties/[id]/paid/route.ts index 1d2101bc..2cc8aa44 100644 --- a/app/api/bounties/[id]/paid/route.ts +++ b/app/api/bounties/[id]/paid/route.ts @@ -2,15 +2,20 @@ * POST /api/bounties/[id]/paid * * Poster proves payment with an on-chain sBTC txid. Verification chain: - * - txid not already redeemed by another bounty (cheap pre-check) + * - txid not already redeemed (cheap KV pre-check) * - tx exists on Hiro, anchored, status=success * - sBTC `transfer` contract call - * - sender = poster, recipient = winner, amount >= rewardSats + * - sender = poster, recipient = winner, amount >= rewardSats / maxWinners * - memo = BNTY:{bountyId} (the anti-fraud binding) - * - block_time > acceptedAt - 60s + * - block_time > winnerAcceptedAt - 60s * - * Allowed only when bounty's derived status is `winner-announced`. The - * canonical txid that Hiro returns is what we store (not the raw input). + * Allowed only when bounty's derived status is `winner-announced` (all winner + * slots filled, at least one unpaid). For multi-winner bounties, the caller + * must supply `submissionId` to indicate which winner this txid pays. For + * single-winner bounties `submissionId` is optional (auto-derived). + * + * The canonical txid that Hiro returns is what we store (not the raw input). + * Once all winners are paid, `bountyStatus()` returns `"paid"`. */ import { NextRequest, NextResponse } from "next/server"; @@ -23,10 +28,12 @@ import { buildPaidMessage, getBounty, getSubmission, + getWinner, + getWinners, isTxidRedeemed, isWithinSignatureWindow, reserveTxid, - setPaid, + setWinnerPaid, validatePaid, verifyPayoutTxid, } from "@/lib/bounty"; @@ -68,7 +75,7 @@ export async function POST( const bounty = await getBounty(db, id); if (!bounty) return NextResponse.json({ error: "not_found" }, { status: 404 }); - // Verify signature against poster + // Verify signature against poster — format unchanged for backward compat. const message = buildPaidMessage({ bountyId: bounty.id, txid: data.txid, @@ -93,30 +100,76 @@ export async function POST( ); } - // Status guard — must be winner-announced + // Status guard — must be winner-announced (all slots filled, some unpaid) const status = bountyStatus(bounty); if (status !== "winner-announced") { return NextResponse.json( { error: "invalid_state", - message: `Cannot mark paid in status "${status}". Accept a submission first.`, + message: `Cannot mark paid in status "${status}". All winner slots must be filled first.`, status, + winnerCount: bounty.winnerCount, + paidCount: bounty.paidCount, + maxWinners: bounty.maxWinners, }, { status: 422 } ); } - if (!bounty.acceptedSubmissionId) { + // Resolve which winner this payment is for. + let submissionId = data.submissionId; + if (!submissionId) { + if (bounty.maxWinners > 1) { + // Multi-winner requires explicit submissionId routing. + return NextResponse.json( + { + error: "submission_id_required", + message: `This bounty has ${bounty.maxWinners} winners. Provide submissionId to identify which winner this txid pays.`, + hint: "Fetch GET /api/bounties/{id} and use the winners[] array to find the submissionId for each unpaid winner.", + }, + { status: 400 } + ); + } + // Single-winner: auto-derive from winners table (or legacy field). + const winners = await getWinners(db, bounty.id); + const unpaid = winners.find((w) => !w.paidAt); + submissionId = unpaid?.submissionId ?? bounty.acceptedSubmissionId; + } + + if (!submissionId) { return NextResponse.json( - { error: "invalid_state", message: "Bounty has no accepted submission." }, + { error: "invalid_state", message: "Bounty has no accepted submission to pay." }, { status: 422 } ); } - const acceptedSubmission = await getSubmission(db, bounty.acceptedSubmissionId); + // Fetch the winner row and the submission + const winnerRow = await getWinner(db, bounty.id, submissionId); + if (!winnerRow) { + return NextResponse.json( + { + error: "winner_not_found", + message: `Submission ${submissionId} is not a winner of this bounty.`, + }, + { status: 404 } + ); + } + if (winnerRow.paidAt) { + return NextResponse.json( + { + error: "already_paid", + message: "This winner has already been paid.", + paidAt: winnerRow.paidAt, + paidTxid: winnerRow.paidTxid, + }, + { status: 409 } + ); + } + + const acceptedSubmission = await getSubmission(db, submissionId); if (!acceptedSubmission) { return NextResponse.json( - { error: "submission_not_found", message: "Accepted submission record missing." }, + { error: "submission_not_found", message: "Winner submission record missing." }, { status: 500 } ); } @@ -134,17 +187,23 @@ export async function POST( ); } + // Per-slot expected amount: total reward divided equally. + const expectedAmountSats = Math.floor(bounty.rewardSats / bounty.maxWinners); + // On-chain verification via Hiro const verify = await verifyPayoutTxid({ txid: data.txid, bounty, acceptedSubmission, + expectedAmountSats, + winnerAcceptedAt: winnerRow.acceptedAt, logger, }); if (!verify.ok) { const statusCode = verify.code === "TX_NOT_CONFIRMED" ? 422 : 400; logger.warn("bounty.paid_verification_failed", { bountyId: bounty.id, + submissionId, code: verify.code, txid: data.txid, }); @@ -158,22 +217,22 @@ export async function POST( ); } - // Persist — use Hiro's canonical tx_id as the stored value. + // Persist — use Hiro's canonical tx_id. const paidAt = verify.blockTimeIso ?? new Date().toISOString(); let ok = false; try { - ok = await setPaid(db, bounty.id, verify.canonicalTxid, paidAt); + ok = await setWinnerPaid(db, bounty.id, submissionId, verify.canonicalTxid, paidAt); } catch (e) { - // D1 unique partial index conflict — same canonical txid paid another bounty. logger.warn("bounty.paid_unique_violation", { bountyId: bounty.id, + submissionId, canonicalTxid: verify.canonicalTxid, error: String(e), }); return NextResponse.json( { error: "txid_already_redeemed", - message: "This canonical txid has already paid another bounty.", + message: "This canonical txid has already paid a winner.", }, { status: 409 } ); @@ -185,11 +244,7 @@ export async function POST( ); } - // D1 has already committed the paid_txid — the unique partial index is - // the durable enforcement. KV reservation is the cheap pre-check for - // *future* /paid requests against other bounties; if it fails (KV blip), - // log and keep going so the user doesn't see a 500 after a successful - // payment. + // D1 committed — KV reservation is a best-effort pre-check for future requests. try { await reserveTxid(kv, verify.canonicalTxid, bounty.id); } catch (e) { @@ -202,8 +257,11 @@ export async function POST( logger.info("bounty.paid", { bountyId: bounty.id, + submissionId, canonicalTxid: verify.canonicalTxid, paidAt, + paidCount: bounty.paidCount + 1, + maxWinners: bounty.maxWinners, }); const fresh = await getBounty(db, bounty.id); diff --git a/app/api/bounties/[id]/route.ts b/app/api/bounties/[id]/route.ts index 41463412..781b3d6c 100644 --- a/app/api/bounties/[id]/route.ts +++ b/app/api/bounties/[id]/route.ts @@ -2,9 +2,13 @@ * GET /api/bounties/[id] * * Detail endpoint. Returns the bounty record, its computed status, the first - * page of submissions, and — when applicable — denormalized `winner` and - * `payment` blocks so the poster sees exactly who they picked and exactly - * what memo + recipient + amount to use for payout. + * page of submissions, and — when applicable — a `winners` array and a + * `payments` array so the poster sees exactly who they picked and exactly + * what memo + recipient + amount to use for each payout. + * + * For single-winner bounties: `winners` contains at most one element; + * `payments` contains at most one hint. The legacy `winner` and `payment` + * singular fields are also included for backward compatibility. */ import { NextRequest, NextResponse } from "next/server"; @@ -13,7 +17,8 @@ import { bountyStatus, buildExpectedMemo, getBounty, - getSubmission, + getSubmissionsByIds, + getWinners, listSubmissionsForBounty, SBTC_CONTRACT_MAINNET, type BountyPaymentHint, @@ -21,6 +26,7 @@ import { type BountyStatus, type BountySubmission, type BountyWinner, + type BountyWinnerRow, } from "@/lib/bounty"; export async function GET( @@ -49,36 +55,35 @@ export async function GET( const now = new Date(); const status: BountyStatus = bountyStatus(bounty, now); - const { submissions, total: submissionCount } = await listSubmissionsForBounty( - db, - bounty.id, - 20, - 0 - ); + const [{ submissions, total: submissionCount }, winnerRows] = await Promise.all([ + listSubmissionsForBounty(db, bounty.id, 20, 0), + getWinners(db, bounty.id), + ]); - // Winner block — populated whenever the bounty has acceptedAt (i.e. on - // winner-announced, paid, and abandoned-after-accept). - let winner: BountyWinner | undefined; - if (bounty.acceptedSubmissionId && bounty.acceptedAt) { - const winningSub = - submissions.find((s) => s.id === bounty.acceptedSubmissionId) ?? - (await getSubmission(db, bounty.acceptedSubmissionId)); - if (winningSub) { - winner = buildWinner(winningSub, bounty.acceptedAt); - } - } + // Build the winners[] array from bounty_winners join table + submissions. + // Fall back to a single-winner built from legacy fields for pre-023 rows + // that somehow missed the backfill. + const winners: BountyWinner[] = await buildWinnersArray(db, bounty, winnerRows, submissions); - // Payment block — only meaningful when status is winner-announced. - let payment: BountyPaymentHint | undefined; - if (status === "winner-announced" && winner) { - payment = buildPaymentHint(bounty, winner.submitterStxAddress); - } + // Payment hints — one per unpaid winner, only when status is winner-announced. + const payments: BountyPaymentHint[] = status === "winner-announced" + ? winners + .filter((w) => !w.paidAt) + .map((w) => buildPaymentHint(bounty, w.submitterStxAddress)) + : []; + + // Backward-compat singular fields (first winner / first payment hint). + const winner = winners[0] as BountyWinner | undefined; + const payment = payments[0] as BountyPaymentHint | undefined; return NextResponse.json( { bounty: { ...bounty, status }, submissions, submissionCount, + winners, + ...(payments.length > 0 && { payments }), + // Singular compat fields — callers should migrate to winners[] / payments[] ...(winner && { winner }), ...(payment && { payment }), }, @@ -90,7 +95,48 @@ export async function GET( ); } -function buildWinner(s: BountySubmission, acceptedAt: string): BountyWinner { +async function buildWinnersArray( + db: Parameters[0], + bounty: BountyRecord, + winnerRows: BountyWinnerRow[], + submissions: BountySubmission[] +): Promise { + if (winnerRows.length === 0) { + // Pre-023 fallback: synthesize from legacy bounty fields if present. + if (bounty.acceptedSubmissionId && bounty.acceptedAt) { + const cached = submissions.find((s) => s.id === bounty.acceptedSubmissionId); + const extra = cached ? [] : await getSubmissionsByIds(db, [bounty.acceptedSubmissionId]); + const sub = cached ?? extra[0]; + if (sub) { + return [buildWinner(sub, bounty.acceptedAt, bounty.paidAt, bounty.paidTxid)]; + } + } + return []; + } + + // Batch-fetch any winner submissions not already in the first-page cache. + // `submissions` covers at most 20 rows; winners may have submitted later. + const subMap = new Map(submissions.map((s) => [s.id, s])); + const missingIds = winnerRows.map((r) => r.submissionId).filter((id) => !subMap.has(id)); + if (missingIds.length > 0) { + const extra = await getSubmissionsByIds(db, missingIds); + for (const s of extra) subMap.set(s.id, s); + } + + return winnerRows + .map((row) => { + const sub = subMap.get(row.submissionId); + return sub ? buildWinner(sub, row.acceptedAt, row.paidAt, row.paidTxid) : null; + }) + .filter((w): w is BountyWinner => w !== null); +} + +function buildWinner( + s: BountySubmission, + acceptedAt: string, + paidAt?: string, + paidTxid?: string +): BountyWinner { return { submissionId: s.id, submitterBtcAddress: s.submitterBtcAddress, @@ -98,6 +144,8 @@ function buildWinner(s: BountySubmission, acceptedAt: string): BountyWinner { ...(s.contentUrl && { contentUrl: s.contentUrl }), message: s.message, acceptedAt, + ...(paidAt && { paidAt }), + ...(paidTxid && { paidTxid }), }; } @@ -107,7 +155,7 @@ function buildPaymentHint(bounty: BountyRecord, recipientStxAddress: string): Bo expectedMemo: memo.ascii, expectedMemoHex: memo.hex, recipientStxAddress, - amountSats: bounty.rewardSats, + amountSats: Math.floor(bounty.rewardSats / bounty.maxWinners), sbtcContract: SBTC_CONTRACT_MAINNET, }; } diff --git a/app/api/bounties/route.ts b/app/api/bounties/route.ts index 091ea142..20a46867 100644 --- a/app/api/bounties/route.ts +++ b/app/api/bounties/route.ts @@ -17,6 +17,7 @@ import { DESCRIPTION_MAX, MIN_EXPIRY_HOURS, MAX_EXPIRY_DAYS, + MAX_WINNERS, SIGNATURE_WINDOW_SECONDS, buildCreateMessage, isWithinSignatureWindow, @@ -33,6 +34,7 @@ import { const STATUS_FILTER_VALUES: ReadonlySet = new Set([ "open", "judging", + "partially-filled", "winner-announced", "paid", "abandoned", @@ -50,11 +52,12 @@ function selfDoc(): NextResponse { "Native bounty board. Any registered (L1+) agent posts and submits. Posters accept a winner and prove payment with a confirmed on-chain sBTC txid (memo must be 'BNTY:{bountyId}').", states: { open: "Accepting submissions; now < expiresAt", - judging: "Submissions closed; poster reviewing", - "winner-announced": "Poster accepted a submission; awaiting payment", - paid: "Payment txid verified on-chain (terminal)", + judging: "Submissions closed; poster reviewing (no winners yet)", + "partially-filled": "1..n-1 winners accepted; remaining slots open (multi-winner only)", + "winner-announced": "All winner slots filled; awaiting payment proof(s)", + paid: "All payments verified on-chain (terminal)", abandoned: - "Poster ghosted past a grace window: 14d past expiresAt with no winner, or 7d past acceptedAt with no payment (terminal)", + "Poster ghosted past a grace window: 14d past expiresAt with unfilled slots, or 7d past last accept with unpaid winners (terminal)", cancelled: "Poster killed it before any acceptance (terminal)", }, get: { @@ -76,7 +79,8 @@ function selfDoc(): NextResponse { posterBtcAddress: "Your registered BTC address (bc1...). Must be L1+ (a registered agent).", title: `Short title (1..${TITLE_MAX} chars).`, description: `What needs to be done (1..${DESCRIPTION_MAX} chars, markdown allowed).`, - rewardSats: "Promised sBTC reward, integer > 0.", + rewardSats: `Promised total sBTC reward, integer > 0. For multi-winner: total pot split equally (e.g. 1500 sats / 3 winners = 500 sats each). Must be exactly divisible by maxWinners — indivisible values are rejected at create time.`, + maxWinners: `Optional. How many winners to accept (integer >= 1, default 1). The poster decides — no platform cap. Each winner receives rewardSats / maxWinners sats.`, expiresAt: `ISO timestamp. Min ${MIN_EXPIRY_HOURS}h, max ${MAX_EXPIRY_DAYS}d from now. Submission window closes at this time.`, tags: "Optional string[] (max 5 tags).", signedAt: "ISO timestamp you used when signing (±5 minutes of server time).", @@ -291,6 +295,9 @@ export async function POST(request: NextRequest) { title: data.title, description: data.description, rewardSats: data.rewardSats, + maxWinners: data.maxWinners, + winnerCount: 0, + paidCount: 0, submissionCount: 0, createdAt: nowIso, expiresAt: expiresAtIso, diff --git a/lib/bounty/constants.ts b/lib/bounty/constants.ts index 943c8d52..05623bb4 100644 --- a/lib/bounty/constants.ts +++ b/lib/bounty/constants.ts @@ -33,6 +33,12 @@ export const MAX_EXPIRY_DAYS = 365; /** Minimum participant level (Registered). Applies to both posters and submitters. */ export const MIN_SUBMITTER_LEVEL = 1; +/** + * Maximum winners per bounty. No platform-imposed ceiling — the poster decides. + * This guard exists only to reject clearly malformed inputs (typos, runaway loops). + */ +export const MAX_WINNERS = 1000; + /** Replay window for action signatures (±5 minutes). */ export const SIGNATURE_WINDOW_SECONDS = 300; diff --git a/lib/bounty/d1-helpers.ts b/lib/bounty/d1-helpers.ts index 08b8aa62..b2fc979e 100644 --- a/lib/bounty/d1-helpers.ts +++ b/lib/bounty/d1-helpers.ts @@ -9,8 +9,9 @@ * filter and compiles it to the matching SQL predicate via `statusToSql`. */ -import type { BountyRecord, BountyStatus, BountySubmission } from "./types"; +import type { BountyRecord, BountyStatus, BountySubmission, BountyWinnerRow } from "./types"; import { ACCEPT_GRACE_MS, PAY_GRACE_MS } from "./constants"; +import { generateWinnerId } from "./id"; // --------------------------------------------------------------------------- // Row shapes (snake_case as returned by D1) + mappers @@ -26,6 +27,12 @@ interface D1BountyRow { submission_count: number; created_at: string; expires_at: string; + // Multi-winner fields (migration 023) + max_winners: number; + winner_count: number; + paid_count: number; + fully_accepted_at: string | null; + // Legacy single-winner fields — kept for backward compat + display; use bounty_winners for logic accepted_submission_id: string | null; accepted_at: string | null; paid_txid: string | null; @@ -35,6 +42,15 @@ interface D1BountyRow { tags: string | null; } +interface D1WinnerRow { + id: string; + bounty_id: string; + submission_id: string; + accepted_at: string; + paid_txid: string | null; + paid_at: string | null; +} + interface D1SubmissionRow { id: string; bounty_id: string; @@ -56,6 +72,10 @@ function rowToBounty(row: D1BountyRow): BountyRecord { submissionCount: row.submission_count, createdAt: row.created_at, expiresAt: row.expires_at, + maxWinners: row.max_winners ?? 1, + winnerCount: row.winner_count ?? 0, + paidCount: row.paid_count ?? 0, + ...(row.fully_accepted_at != null && { fullyAcceptedAt: row.fully_accepted_at }), ...(row.accepted_submission_id != null && { acceptedSubmissionId: row.accepted_submission_id, }), @@ -68,6 +88,17 @@ function rowToBounty(row: D1BountyRow): BountyRecord { }; } +function rowToWinner(row: D1WinnerRow): BountyWinnerRow { + return { + id: row.id, + bountyId: row.bounty_id, + submissionId: row.submission_id, + acceptedAt: row.accepted_at, + ...(row.paid_txid != null && { paidTxid: row.paid_txid }), + ...(row.paid_at != null && { paidAt: row.paid_at }), + }; +} + function safeParseTags(value: string): { tags?: string[] } { try { const parsed = JSON.parse(value); @@ -115,41 +146,54 @@ export function statusToSql( const acceptCutoffIso = new Date(now.getTime() - ACCEPT_GRACE_MS).toISOString(); const payCutoffIso = new Date(now.getTime() - PAY_GRACE_MS).toISOString(); + // All predicates use the denormalized counters (winner_count, paid_count, + // max_winners, fully_accepted_at) added by migration 023. These are kept in + // sync by insertWinner() and setWinnerPaid(). The predicates must mirror the + // check order in bountyStatus() in lib/bounty/types.ts so per-record status + // and list-filter status agree at every tick (status-boundary parity test). switch (status) { case "open": + // No winners yet, submission window still open. return { - sql: - "cancelled_at IS NULL AND paid_at IS NULL AND accepted_at IS NULL AND expires_at > ?", + sql: "cancelled_at IS NULL AND winner_count = 0 AND expires_at > ?", bindings: [nowIso], }; case "judging": + // No winners, past expiry but within accept grace. return { - sql: - "cancelled_at IS NULL AND paid_at IS NULL AND accepted_at IS NULL AND expires_at <= ? AND expires_at > ?", + sql: "cancelled_at IS NULL AND winner_count = 0 AND expires_at <= ? AND expires_at > ?", bindings: [nowIso, acceptCutoffIso], }; + case "partially-filled": + // Some slots accepted, more remain, within accept grace window. + return { + sql: "cancelled_at IS NULL AND winner_count > 0 AND winner_count < max_winners AND expires_at > ?", + bindings: [acceptCutoffIso], + }; case "winner-announced": + // All slots filled, awaiting payment(s), within pay grace. return { - sql: - "cancelled_at IS NULL AND paid_at IS NULL AND accepted_at IS NOT NULL AND accepted_at > ?", + sql: "cancelled_at IS NULL AND paid_count < max_winners AND winner_count >= max_winners AND fully_accepted_at > ?", bindings: [payCutoffIso], }; case "paid": - return { sql: "paid_at IS NOT NULL", bindings: [] }; + return { sql: "paid_count >= max_winners", bindings: [] }; case "abandoned": - // Half-open intervals: `t >= expires_at + ACCEPT_GRACE_MS` (no winner) - // or `t >= accepted_at + PAY_GRACE_MS` (winner picked, no payment). - // Rewritten: `expires_at <= acceptCutoff` / `accepted_at <= payCutoff`. - // Matches `bountyStatus()` in lib/bounty/types.ts at the exact tick. + // Either: not all slots filled and past accept grace, + // or: all slots filled but not all paid and past pay grace. return { sql: - "cancelled_at IS NULL AND paid_at IS NULL AND ((accepted_at IS NULL AND expires_at <= ?) OR (accepted_at IS NOT NULL AND accepted_at <= ?))", + "cancelled_at IS NULL AND paid_count < max_winners AND (" + + "(winner_count < max_winners AND expires_at <= ?) OR " + + "(winner_count >= max_winners AND fully_accepted_at IS NOT NULL AND fully_accepted_at <= ?)" + + ")", bindings: [acceptCutoffIso, payCutoffIso], }; case "cancelled": return { sql: "cancelled_at IS NOT NULL", bindings: [] }; case "active": - return { sql: "cancelled_at IS NULL AND paid_at IS NULL", bindings: [] }; + // All non-terminal states. + return { sql: "cancelled_at IS NULL AND paid_count < max_winners", bindings: [] }; case undefined: return { sql: "1=1", bindings: [] }; } @@ -162,6 +206,7 @@ export function statusToSql( const BOUNTY_COLUMNS = ` id, poster_btc_address, poster_stx_address, title, description, reward_sats, submission_count, created_at, expires_at, + max_winners, winner_count, paid_count, fully_accepted_at, accepted_submission_id, accepted_at, paid_txid, paid_at, cancelled_at, updated_at, tags `; @@ -288,8 +333,9 @@ export async function insertBounty( .prepare( `INSERT INTO bounties ( id, poster_btc_address, poster_stx_address, title, description, - reward_sats, submission_count, created_at, expires_at, updated_at, tags - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + reward_sats, submission_count, created_at, expires_at, updated_at, tags, + max_winners, winner_count, paid_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)` ) .bind( bounty.id, @@ -302,75 +348,168 @@ export async function insertBounty( bounty.createdAt, bounty.expiresAt, bounty.updatedAt, - bounty.tags && bounty.tags.length > 0 ? JSON.stringify(bounty.tags) : null + bounty.tags && bounty.tags.length > 0 ? JSON.stringify(bounty.tags) : null, + bounty.maxWinners ?? 1 ) .run(); } /** - * Mark a bounty as accepted with a chosen submission. + * Accept a submission as a winner. * - * The WHERE clause guards against concurrent acceptance — only flips a bounty - * that still has no `accepted_at` and isn't cancelled or paid. The - * `expires_at > acceptCutoff` predicate closes the TOCTOU window where a - * request straddling the 14-day accept-grace cutoff could resurrect an - * `abandoned` bounty (terminal state is now SQL-enforced, not just - * read-time-derived). + * Inserts a row into `bounty_winners` and atomically increments `winner_count` + * on the parent bounty (in a single D1 batch). The UPDATE WHERE guard enforces: + * - a slot is available (`winner_count < max_winners`) + * - the bounty is not cancelled or fully paid + * - the accept-grace window has not expired (`expires_at > acceptCutoff`) * - * Returns `true` when the row was updated, `false` when the bounty was not in - * an acceptable state (race, already accepted, past the abandonment cutoff). + * When `winner_count + 1 = max_winners`, also sets `fully_accepted_at` so the + * pay-grace window can be computed without a join. + * + * Returns: + * `"ok"` — inserted successfully + * `"conflict"` — no slot available or past accept-grace (race / already full) + * `"duplicate"` — this submission is already a winner (UNIQUE constraint) */ -export async function setAccepted( +export async function insertWinner( db: D1Database, bountyId: string, submissionId: string, acceptedAt: string -): Promise { +): Promise<"ok" | "conflict" | "duplicate"> { const acceptCutoff = new Date(Date.parse(acceptedAt) - ACCEPT_GRACE_MS).toISOString(); - const result = await db - .prepare( - `UPDATE bounties - SET accepted_submission_id = ?, accepted_at = ?, updated_at = ? - WHERE id = ? - AND accepted_at IS NULL - AND cancelled_at IS NULL - AND paid_at IS NULL - AND expires_at > ?` - ) - .bind(submissionId, acceptedAt, acceptedAt, bountyId, acceptCutoff) - .run(); - return (result.meta?.changes ?? 0) > 0; + const winnerId = generateWinnerId(); + + try { + const [insertResult, updateResult] = await db.batch([ + // Conditional INSERT — only runs if a slot is available. + // Uses SELECT FROM bounties so the guard runs atomically with the INSERT. + db + .prepare( + `INSERT INTO bounty_winners (id, bounty_id, submission_id, accepted_at, created_at) + SELECT ?, b.id, ?, ?, ? + FROM bounties b + WHERE b.id = ? + AND b.winner_count < b.max_winners + AND b.cancelled_at IS NULL + AND b.paid_count < b.max_winners + AND b.expires_at > ?` + ) + .bind(winnerId, submissionId, acceptedAt, acceptedAt, bountyId, acceptCutoff), + + // Increment counter; set fully_accepted_at when last slot is filled. + db + .prepare( + `UPDATE bounties + SET winner_count = winner_count + 1, + fully_accepted_at = CASE + WHEN winner_count + 1 = max_winners THEN ? + ELSE fully_accepted_at + END, + updated_at = ? + WHERE id = ? + AND winner_count < max_winners + AND cancelled_at IS NULL + AND paid_count < max_winners + AND expires_at > ?` + ) + .bind(acceptedAt, acceptedAt, bountyId, acceptCutoff), + ]); + + const inserted = (insertResult.meta?.changes ?? 0) > 0; + const updated = (updateResult.meta?.changes ?? 0) > 0; + if (inserted && updated) return "ok"; + return "conflict"; // slot full, cancelled, or past grace — both returned 0 changes + } catch (e) { + if (String(e).includes("UNIQUE constraint failed")) return "duplicate"; + throw e; + } } /** - * Mark a bounty as paid with the verified payout txid. + * Mark one winner's payment as proven on-chain. + * + * Updates the `bounty_winners` row and increments `paid_count` on the parent + * bounty in a D1 batch. The `bounty_winners` WHERE guard (`paid_at IS NULL`) + * prevents double-payment for the same winner. The unique partial index on + * `bounty_winners.paid_txid` enforces one-txid-per-winner at the DB level. * - * Guarded by `accepted_at IS NOT NULL AND paid_at IS NULL` so a bounty can - * only be flipped to paid from `winner-announced`. The `accepted_at > payCutoff` - * predicate closes the TOCTOU window where a request straddling the 7-day - * pay-grace cutoff could flip an `abandoned` bounty to `paid`. The unique - * partial index on `paid_txid` enforces one-txid-per-bounty at the DB level. + * Returns `true` on success, `false` on conflict (already paid or state changed). */ -export async function setPaid( +export async function setWinnerPaid( db: D1Database, bountyId: string, + submissionId: string, paidTxid: string, paidAt: string ): Promise { const payCutoff = new Date(Date.parse(paidAt) - PAY_GRACE_MS).toISOString(); - const result = await db + + // Run sequentially, NOT as a D1 batch. + // + // D1 batches are atomic but statements are not causally linked: statement 2 + // evaluates its own WHERE clause independently of statement 1's changes count. + // If two concurrent requests both read winnerRow.paidAt = null, the second + // request's batch would see stmt1 get 0 changes (paid_at already set by the + // first request) but stmt2 could still satisfy `paid_count < max_winners` and + // increment paid_count past the actual number of paid winners — permanently + // stranding an unpaid winner in a terminal "paid" bounty. + // + // By checking winnerResult.changes before issuing the bounty update we guarantee + // paid_count only increments when THIS request actually set paid_at. + // + // Do NOT catch UNIQUE constraint failures here — let them propagate. + // The unique partial index on bounty_winners.paid_txid is the durable guard + // against txid reuse; the /paid route's try/catch handles it as txid_already_redeemed. + const winnerResult = await db + .prepare( + `UPDATE bounty_winners + SET paid_txid = ?, paid_at = ? + WHERE bounty_id = ? AND submission_id = ? AND paid_at IS NULL` + ) + .bind(paidTxid, paidAt, bountyId, submissionId) + .run(); + + if ((winnerResult.meta?.changes ?? 0) === 0) { + return false; + } + + const bountyResult = await db .prepare( `UPDATE bounties - SET paid_txid = ?, paid_at = ?, updated_at = ? + SET paid_count = paid_count + 1, updated_at = ? WHERE id = ? - AND accepted_at IS NOT NULL - AND paid_at IS NULL + AND winner_count >= max_winners + AND paid_count < max_winners AND cancelled_at IS NULL - AND accepted_at > ?` + AND fully_accepted_at > ?` ) - .bind(paidTxid, paidAt, paidAt, bountyId, payCutoff) + .bind(paidAt, bountyId, payCutoff) .run(); - return (result.meta?.changes ?? 0) > 0; + + const bountyUpdated = (bountyResult.meta?.changes ?? 0) > 0; + + if (!bountyUpdated) { + // Grace-boundary inconsistency: the winner row was written (paid_at set, + // payment is real) but the bounty counter update was rejected. This happens + // when the pay grace window expires in the narrow gap between the route's + // status check and this write — fully_accepted_at > payCutoff fails. + // Result: paid_count under-counts, bountyStatus() may show "abandoned" + // while GET /api/bounties/{id} shows the winner as paid. + // This state is detectable: bounty_winners.paid_at IS NOT NULL with + // paid_count < actual paid rows. Ops can reconcile by re-running: + // UPDATE bounties SET paid_count = (SELECT COUNT(*) FROM bounty_winners + // WHERE bounty_id = ? AND paid_at IS NOT NULL) WHERE id = ? + console.error("[setWinnerPaid] grace-boundary inconsistency", { + bountyId, + submissionId, + paidTxid, + paidAt, + detail: "winner row written but paid_count not incremented — pay grace may have expired mid-request", + }); + } + + return bountyUpdated; } /** @@ -400,6 +539,41 @@ export async function setCancelled( return (result.meta?.changes ?? 0) > 0; } +// --------------------------------------------------------------------------- +// Winner reads +// --------------------------------------------------------------------------- + +const WINNER_COLUMNS = `id, bounty_id, submission_id, accepted_at, paid_txid, paid_at`; + +/** Fetch all winners for a bounty, ordered by accepted_at ASC. */ +export async function getWinners( + db: D1Database, + bountyId: string +): Promise { + const rows = await db + .prepare( + `SELECT ${WINNER_COLUMNS} FROM bounty_winners WHERE bounty_id = ? ORDER BY accepted_at ASC` + ) + .bind(bountyId) + .all(); + return (rows.results ?? []).map(rowToWinner); +} + +/** Fetch a single winner row by bountyId + submissionId. Returns null if not found. */ +export async function getWinner( + db: D1Database, + bountyId: string, + submissionId: string +): Promise { + const row = await db + .prepare( + `SELECT ${WINNER_COLUMNS} FROM bounty_winners WHERE bounty_id = ? AND submission_id = ? LIMIT 1` + ) + .bind(bountyId, submissionId) + .first(); + return row ? rowToWinner(row) : null; +} + // --------------------------------------------------------------------------- // Submission reads + writes // --------------------------------------------------------------------------- @@ -516,6 +690,24 @@ export async function insertSubmission( ]); } +/** + * Fetch multiple submissions by ID in a single IN (?) query. + * Used by the detail GET to avoid N+1 when winners submitted outside the + * first-page cache (`listSubmissionsForBounty` returns at most 20 rows). + */ +export async function getSubmissionsByIds( + db: D1Database, + ids: string[] +): Promise { + if (ids.length === 0) return []; + const placeholders = ids.map(() => "?").join(", "); + const rows = await db + .prepare(`SELECT ${SUBMISSION_COLUMNS} FROM bounty_submissions WHERE id IN (${placeholders})`) + .bind(...ids) + .all(); + return (rows.results ?? []).map(rowToSubmission); +} + /** Quick existence check used by the self-submit guard. */ export async function hasSubmission( db: D1Database, diff --git a/lib/bounty/id.ts b/lib/bounty/id.ts index e936703b..d586670f 100644 --- a/lib/bounty/id.ts +++ b/lib/bounty/id.ts @@ -6,12 +6,16 @@ * 5-byte `BNTY:` prefix). Roughly sortable by creation time. */ -/** Generate a new bounty id. */ -export function generateBountyId(): string { +/** Shared implementation for all timestamp-based IDs in the bounty system. */ +function generateTimestampId(): string { return `${Date.now().toString(36)}${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; } +/** Generate a new bounty id. */ +export const generateBountyId = generateTimestampId; + /** Generate a new submission id. Same format as bounty id; lives in its own table. */ -export function generateSubmissionId(): string { - return `${Date.now().toString(36)}${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; -} +export const generateSubmissionId = generateTimestampId; + +/** Generate a new winner row id for the bounty_winners join table. */ +export const generateWinnerId = generateTimestampId; diff --git a/lib/bounty/index.ts b/lib/bounty/index.ts index 291fea3e..c283016e 100644 --- a/lib/bounty/index.ts +++ b/lib/bounty/index.ts @@ -10,6 +10,7 @@ export type { BountyRecord, BountySubmission, BountyWinner, + BountyWinnerRow, BountyPaymentHint, } from "./types"; export { bountyStatus } from "./types"; @@ -23,6 +24,7 @@ export { TAG_LENGTH_MAX, MIN_EXPIRY_HOURS, MAX_EXPIRY_DAYS, + MAX_WINNERS, MIN_SUBMITTER_LEVEL, SIGNATURE_WINDOW_SECONDS, ACCEPT_GRACE_MS, @@ -59,9 +61,11 @@ export { getBounty, listBounties, insertBounty, - setAccepted, - setPaid, + insertWinner, + setWinnerPaid, setCancelled, + getWinners, + getWinner, getSubmission, listSubmissionsForBounty, listSubmissionsBySubmitter, @@ -71,7 +75,7 @@ export { export { isTxidRedeemed, reserveTxid } from "./kv-helpers"; -export { generateBountyId, generateSubmissionId } from "./id"; +export { generateBountyId, generateSubmissionId, generateWinnerId } from "./id"; export type { TxidVerifyFailureCode, TxidVerifyResult } from "./txid-verify"; export { buildExpectedMemo, verifyPayoutTxid } from "./txid-verify"; diff --git a/lib/bounty/txid-verify.ts b/lib/bounty/txid-verify.ts index 1a6d5e74..ad67683c 100644 --- a/lib/bounty/txid-verify.ts +++ b/lib/bounty/txid-verify.ts @@ -95,6 +95,16 @@ export async function verifyPayoutTxid(params: { txid: string; bounty: BountyRecord; acceptedSubmission: BountySubmission; + /** + * Per-slot expected amount in sats. Defaults to `bounty.rewardSats` for + * single-winner bounties. For multi-winner, pass `bounty.rewardSats / bounty.maxWinners`. + */ + expectedAmountSats?: number; + /** + * The specific winner's acceptedAt (from bounty_winners row). Overrides + * `bounty.acceptedAt` for the TX_TOO_OLD check when paying a specific winner. + */ + winnerAcceptedAt?: string; /** * Override the HTTP fetcher. Defaults to `stacksApiFetch()` from * `lib/stacks-api-fetch.ts` (the canonical helper with retry + 429 @@ -266,11 +276,12 @@ export async function verifyPayoutTxid(params: { message: "Could not determine transfer amount.", }; } - if (amount < params.bounty.rewardSats) { + const expectedAmount = params.expectedAmountSats ?? params.bounty.rewardSats; + if (amount < expectedAmount) { return { ok: false, code: "AMOUNT_TOO_LOW", - message: `Transferred ${amount} sats < promised ${params.bounty.rewardSats} sats.`, + message: `Transferred ${amount} sats < expected ${expectedAmount} sats (${params.bounty.rewardSats} total / ${params.bounty.maxWinners ?? 1} winner${(params.bounty.maxWinners ?? 1) > 1 ? "s" : ""}).`, }; } @@ -293,7 +304,12 @@ export async function verifyPayoutTxid(params: { // landed after `acceptedAt` but were anchored to an older Bitcoin block. const blockTimeIso = tx.block_time_iso ?? tx.burn_block_time_iso ?? now.toISOString(); const blockTimeMs = Date.parse(blockTimeIso); - const acceptedMs = params.bounty.acceptedAt ? Date.parse(params.bounty.acceptedAt) : 0; + // Prefer the specific winner's acceptedAt over the legacy bounty-level field. + const acceptedMs = params.winnerAcceptedAt + ? Date.parse(params.winnerAcceptedAt) + : params.bounty.acceptedAt + ? Date.parse(params.bounty.acceptedAt) + : 0; if (!Number.isNaN(blockTimeMs) && blockTimeMs + 60_000 < acceptedMs) { return { ok: false, diff --git a/lib/bounty/types.ts b/lib/bounty/types.ts index 307b9c74..8a125932 100644 --- a/lib/bounty/types.ts +++ b/lib/bounty/types.ts @@ -14,18 +14,20 @@ import { ACCEPT_GRACE_MS, PAY_GRACE_MS } from "./constants"; /** - * The six observable states of a bounty. + * The observable states of a bounty. * - * - `open` — accepting submissions; now < expiresAt - * - `judging` — submissions closed, poster reviewing; now >= expiresAt, no winner yet - * - `winner-announced` — poster accepted a submission; awaiting payment proof - * - `paid` — payment txid verified on-chain (terminal) - * - `abandoned` — poster ghosted past a grace window (terminal) - * - `cancelled` — poster killed it before any acceptance (terminal) + * - `open` — accepting submissions; now < expiresAt + * - `judging` — submissions closed, poster reviewing; now >= expiresAt, no winner yet + * - `partially-filled` — poster accepted 1..n-1 winners; slots still remain (multi-winner only) + * - `winner-announced` — all winner slots filled; awaiting payment proof(s) + * - `paid` — all payments verified on-chain (terminal) + * - `abandoned` — poster ghosted past a grace window (terminal) + * - `cancelled` — poster killed it before any acceptance (terminal) */ export type BountyStatus = | "open" | "judging" + | "partially-filled" | "winner-announced" | "paid" | "abandoned" @@ -51,11 +53,21 @@ export interface BountyRecord { createdAt: string; /** ISO. Submissions close at this time. */ expiresAt: string; + /** Max number of winners. Defaults to 1 (single-winner). */ + maxWinners: number; + /** Denormalized count of accepted winners (0..maxWinners). Kept in sync by insertWinner(). */ + winnerCount: number; + /** Denormalized count of paid winners (0..maxWinners). Kept in sync by setWinnerPaid(). */ + paidCount: number; + /** ISO. Set when winnerCount first reaches maxWinners (i.e. all slots filled). Used for pay-grace timing. */ + fullyAcceptedAt?: string; + /** @deprecated Single-winner compat field. Use bounty_winners table via getWinners(). */ acceptedSubmissionId?: string; - /** ISO. Winner announced. */ + /** @deprecated Single-winner compat field. */ acceptedAt?: string; + /** @deprecated Single-winner compat field. */ paidTxid?: string; - /** ISO. Payment proven on-chain. */ + /** @deprecated Single-winner compat field. */ paidAt?: string; /** ISO. Poster cancelled before acceptance. */ cancelledAt?: string; @@ -95,12 +107,30 @@ export interface BountySubmission { */ export function bountyStatus(b: BountyRecord, now: Date = new Date()): BountyStatus { const t = now.getTime(); - if (b.paidAt) return "paid"; + const maxWinners = b.maxWinners ?? 1; + // Prefer the denormalized counters (set by migration + insertWinner/setWinnerPaid). + // Fall back to legacy single-winner fields for any records pre-dating migration 023. + const winnerCount = b.winnerCount ?? (b.acceptedAt ? 1 : 0); + const paidCount = b.paidCount ?? (b.paidAt ? 1 : 0); + + if (paidCount >= maxWinners) return "paid"; if (b.cancelledAt) return "cancelled"; - if (b.acceptedAt) { - if (t >= Date.parse(b.acceptedAt) + PAY_GRACE_MS) return "abandoned"; + + if (winnerCount >= maxWinners) { + // All slots filled — waiting on payment(s). + // Use fullyAcceptedAt when available; fall back to single-winner acceptedAt. + const fullyAt = b.fullyAcceptedAt ?? b.acceptedAt; + if (!fullyAt || t >= Date.parse(fullyAt) + PAY_GRACE_MS) return "abandoned"; return "winner-announced"; } + + if (winnerCount > 0) { + // Some slots filled, more remain. Accept grace runs from expiresAt. + if (t >= Date.parse(b.expiresAt) + ACCEPT_GRACE_MS) return "abandoned"; + return "partially-filled"; + } + + // No winners yet. if (t >= Date.parse(b.expiresAt) + ACCEPT_GRACE_MS) return "abandoned"; if (t >= Date.parse(b.expiresAt)) return "judging"; return "open"; @@ -121,6 +151,22 @@ export interface BountyWinner { contentUrl?: string; message: string; acceptedAt: string; + /** Set once the poster proves payment for this winner. */ + paidAt?: string; + paidTxid?: string; +} + +/** + * A row from the `bounty_winners` join table (snake_case → camelCase mapped by rowToWinner). + * Internal to the lib — not surfaced directly in API responses. + */ +export interface BountyWinnerRow { + id: string; + bountyId: string; + submissionId: string; + acceptedAt: string; + paidTxid?: string; + paidAt?: string; } /** diff --git a/lib/bounty/validation.ts b/lib/bounty/validation.ts index 571ad6da..4eb2c5f7 100644 --- a/lib/bounty/validation.ts +++ b/lib/bounty/validation.ts @@ -17,6 +17,7 @@ import { TAG_LENGTH_MAX, MIN_EXPIRY_HOURS, MAX_EXPIRY_DAYS, + MAX_WINNERS, } from "./constants"; /** Re-exported from the inbox pattern so callers can format error responses uniformly. */ @@ -129,6 +130,7 @@ export function validateCreateBounty(body: unknown): description: string; rewardSats: number; expiresAt: string; + maxWinners: number; tags?: string[]; signedAt: string; signature: string; @@ -240,6 +242,52 @@ export function validateCreateBounty(body: unknown): } } + // maxWinners is optional; defaults to 1. Validated if present. + if (b.maxWinners !== undefined) { + if ( + typeof b.maxWinners !== "number" || + !Number.isInteger(b.maxWinners) || + b.maxWinners < 1 || + b.maxWinners > MAX_WINNERS + ) { + errors.push({ + message: `maxWinners must be an integer between 1 and ${MAX_WINNERS}`, + hint: { + field: "maxWinners", + message: `maxWinners must be an integer between 1 and ${MAX_WINNERS}`, + hint: `How many winners this bounty accepts (FCFS). Defaults to 1. rewardSats is the total pot — each winner receives rewardSats / maxWinners sats. No platform cap — you decide.`, + format: `integer >= 1`, + example: "3", + }, + }); + } + } + + // Divisibility check: floor division strands (rewardSats % maxWinners) sats + // with no recovery path. Reject at create time so the poster chooses a + // divisible total up front. + const effectiveMaxWinners = typeof b.maxWinners === "number" && Number.isInteger(b.maxWinners) && b.maxWinners >= 1 + ? b.maxWinners + : 1; + if ( + effectiveMaxWinners > 1 && + typeof b.rewardSats === "number" && + Number.isInteger(b.rewardSats) && + b.rewardSats > 0 && + b.rewardSats % effectiveMaxWinners !== 0 + ) { + const stranded = b.rewardSats % effectiveMaxWinners; + errors.push({ + message: `rewardSats (${b.rewardSats}) must be divisible by maxWinners (${effectiveMaxWinners}) — floor division strands ${stranded} sat${stranded === 1 ? "" : "s"}`, + hint: { + field: "rewardSats", + message: `rewardSats (${b.rewardSats}) must be divisible by maxWinners (${effectiveMaxWinners}) — floor division strands ${stranded} sat${stranded === 1 ? "" : "s"}`, + hint: `Each winner receives rewardSats / maxWinners sats. Use a divisible value (e.g. ${effectiveMaxWinners * Math.ceil(b.rewardSats / effectiveMaxWinners)} or ${effectiveMaxWinners * Math.floor(b.rewardSats / effectiveMaxWinners)}).`, + example: String(effectiveMaxWinners * Math.ceil(b.rewardSats / effectiveMaxWinners)), + }, + }); + } + if (b.tags !== undefined) { if (!Array.isArray(b.tags)) { errors.push({ @@ -301,6 +349,7 @@ export function validateCreateBounty(body: unknown): description: (b.description as string).trim(), rewardSats: b.rewardSats as number, expiresAt: b.expiresAt as string, + maxWinners: typeof b.maxWinners === "number" ? (b.maxWinners as number) : 1, ...(Array.isArray(b.tags) && b.tags.length > 0 && { tags: b.tags as string[] }), signedAt: b.signedAt as string, signature: b.signature as string, @@ -460,7 +509,7 @@ export function validateAccept(body: unknown): /** Validate the POST /api/bounties/[id]/paid body. */ export function validatePaid(body: unknown): - | { data: { txid: string; signedAt: string; signature: string }; errors?: never } + | { data: { txid: string; submissionId?: string; signedAt: string; signature: string }; errors?: never } | { data?: never; errors: ValidationHint[] } { if (!body || typeof body !== "object") { return { @@ -468,7 +517,7 @@ export function validatePaid(body: unknown): { field: "body", message: "Request body must be a JSON object", - hint: "Send JSON with txid, signedAt, signature.", + hint: "Send JSON with txid, signedAt, signature (and submissionId for multi-winner bounties).", }, ], }; @@ -488,6 +537,22 @@ export function validatePaid(body: unknown): }); } + // submissionId is optional for single-winner bounties (derived automatically) + // but required when maxWinners > 1. Validation here is format-only; the route + // enforces the multi-winner requirement once it knows maxWinners. + if (b.submissionId !== undefined) { + if (typeof b.submissionId !== "string" || b.submissionId.length === 0) { + errors.push({ + message: "submissionId must be a non-empty string when provided", + hint: { + field: "submissionId", + message: "submissionId must be a non-empty string", + hint: "The id of the accepted submission you are paying. Required for multi-winner bounties. Get it from GET /api/bounties/{id} (winners[] array).", + }, + }); + } + } + pushIsoTimestampError(errors, b.signedAt, "signedAt", "ISO timestamp used when signing."); pushSignatureError( errors, @@ -500,6 +565,9 @@ export function validatePaid(body: unknown): return { data: { txid: (b.txid as string).trim(), + ...(typeof b.submissionId === "string" && b.submissionId.length > 0 && { + submissionId: b.submissionId, + }), signedAt: b.signedAt as string, signature: b.signature as string, }, diff --git a/migrations/023_bounty_multi_winner.sql b/migrations/023_bounty_multi_winner.sql new file mode 100644 index 00000000..150fb6a7 --- /dev/null +++ b/migrations/023_bounty_multi_winner.sql @@ -0,0 +1,66 @@ +-- Migration 023: multi-winner bounty support +-- +-- Adds: +-- bounties.max_winners — how many winners a bounty accepts (default 1) +-- bounties.winner_count — how many have been accepted so far (denorm) +-- bounties.paid_count — how many have been paid so far (denorm) +-- bounties.fully_accepted_at — ISO timestamp when winner_count first reached max_winners +-- bounty_winners — join table: one row per accepted winner +-- +-- The denorm counters keep bountyStatus() and statusToSql() join-free (no N+1 +-- on list endpoints). The join table is the authoritative record for per-winner +-- state and is read by the detail GET to build the winners[] array. +-- +-- Backfills existing single-winner bounties so the new code path is the only +-- path: winner_count=1/paid_count=1 for accepted/paid rows, and a +-- corresponding bounty_winners row so the detail GET can use getWinners(). + +-- 1. New columns on bounties +ALTER TABLE bounties ADD COLUMN max_winners INTEGER NOT NULL DEFAULT 1; +ALTER TABLE bounties ADD COLUMN winner_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE bounties ADD COLUMN paid_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE bounties ADD COLUMN fully_accepted_at TEXT; + +-- 2. Winner join table +CREATE TABLE IF NOT EXISTS bounty_winners ( + id TEXT PRIMARY KEY, + bounty_id TEXT NOT NULL REFERENCES bounties(id) ON DELETE CASCADE, + submission_id TEXT NOT NULL REFERENCES bounty_submissions(id), + accepted_at TEXT NOT NULL, + paid_txid TEXT, + paid_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(bounty_id, submission_id) +); + +CREATE INDEX IF NOT EXISTS idx_bounty_winners_bounty ON bounty_winners(bounty_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_bounty_winners_paid_txid + ON bounty_winners(paid_txid) WHERE paid_txid IS NOT NULL; + +-- 3. Backfill counters for existing rows +UPDATE bounties +SET + winner_count = CASE WHEN accepted_submission_id IS NOT NULL THEN 1 ELSE 0 END, + paid_count = CASE WHEN paid_at IS NOT NULL THEN 1 ELSE 0 END, + fully_accepted_at = CASE WHEN accepted_submission_id IS NOT NULL THEN accepted_at ELSE NULL END +WHERE 1 = 1; + +-- 4. Backfill bounty_winners for already-accepted bounties +-- +-- NOTE: IDs generated here use `'bw_' || lower(hex(randomblob(8)))` (pure SQL, +-- 16 hex chars of randomness) rather than the runtime format produced by +-- generateWinnerId() (base36 ms timestamp + 12-char UUID slice). The formats +-- differ because the JS runtime is not available inside a migration. Both are +-- unique and opaque to callers; the discrepancy only surfaces when debugging +-- pre-023 winner rows by their ID prefix. +INSERT OR IGNORE INTO bounty_winners (id, bounty_id, submission_id, accepted_at, paid_txid, paid_at, created_at) +SELECT + 'bw_' || lower(hex(randomblob(8))), + id, + accepted_submission_id, + accepted_at, + paid_txid, + paid_at, + COALESCE(accepted_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +FROM bounties +WHERE accepted_submission_id IS NOT NULL;