From f40b4f8101ddd7670eb735931b36e200c465e981 Mon Sep 17 00:00:00 2001 From: Iskander Date: Sun, 14 Jun 2026 22:48:14 +0200 Subject: [PATCH 1/6] feat: multi-winner bounty support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first-class multi-winner support to the AIBTC bounty system. A bounty poster can now specify maxWinners (1–10) at creation time; the accept endpoint may then be called once per slot, and the paid endpoint routes payment to a specific winner via submissionId. ## Schema (migration 023) - bounties.max_winners (default 1, backward compat) - bounties.winner_count / paid_count — denorm counters, keep bountyStatus() and statusToSql() join-free on list endpoints - bounties.fully_accepted_at — when last slot filled (pay-grace anchor) - bounty_winners join table — per-winner accepted_at / paid_txid / paid_at - Backfills all existing accepted/paid rows automatically ## New status - `partially-filled` — 0 < winnerCount < maxWinners, within accept grace ## State machine (bountyStatus + statusToSql kept in parity) open → judging → partially-filled → winner-announced → paid ↓ (past grace) abandoned ## API changes - POST /api/bounties: accepts optional maxWinners (1–10, default 1). rewardSats is the total pot; each winner receives rewardSats/maxWinners. - POST /api/bounties/[id]/accept: now allows partially-filled status. Returns `winnerCount`/`maxWinners` in error responses for diagnostics. - POST /api/bounties/[id]/paid: accepts optional submissionId to route payment to a specific winner (required when maxWinners > 1). Amount validation uses per-slot amount (rewardSats / maxWinners). Signature format unchanged (backward compat). - GET /api/bounties/[id]: returns winners[] array and payments[] array. Singular winner/payment fields kept for backward compat. ## Fixes the reported problems - Multi-winner accept: insertWinner() checks winner_count < max_winners (replaces the single-winner accepted_at IS NULL guard) - Per-slot payment: amount >= rewardSats / maxWinners instead of full pot - No reopen needed: partially-filled bounties stay open for more accepts Co-Authored-By: Claude Sonnet 4.6 --- app/api/bounties/[id]/accept/route.ts | 40 +++- app/api/bounties/[id]/paid/route.ts | 102 +++++++--- app/api/bounties/[id]/route.ts | 97 ++++++--- app/api/bounties/route.ts | 17 +- lib/bounty/constants.ts | 3 + lib/bounty/d1-helpers.ts | 260 +++++++++++++++++++------ lib/bounty/id.ts | 5 + lib/bounty/index.ts | 10 +- lib/bounty/txid-verify.ts | 22 ++- lib/bounty/types.ts | 70 +++++-- lib/bounty/validation.ts | 47 ++++- migrations/023_bounty_multi_winner.sql | 59 ++++++ 12 files changed, 587 insertions(+), 145 deletions(-) create mode 100644 migrations/023_bounty_multi_winner.sql 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..69f210c0 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"; @@ -14,6 +18,7 @@ import { buildExpectedMemo, getBounty, getSubmission, + 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,43 @@ 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 sub = + submissions.find((s) => s.id === bounty.acceptedSubmissionId) ?? + (await getSubmission(db, bounty.acceptedSubmissionId)); + if (sub) { + return [buildWinner(sub, bounty.acceptedAt, bounty.paidAt, bounty.paidTxid)]; + } + } + return []; + } + + // Resolve submissions for each winner row. + const subMap = new Map(submissions.map((s) => [s.id, s])); + const result: BountyWinner[] = []; + for (const row of winnerRows) { + const sub = subMap.get(row.submissionId) ?? (await getSubmission(db, row.submissionId)); + if (sub) { + result.push(buildWinner(sub, row.acceptedAt, row.paidAt, row.paidTxid)); + } + } + return result; +} + +function buildWinner( + s: BountySubmission, + acceptedAt: string, + paidAt?: string, + paidTxid?: string +): BountyWinner { return { submissionId: s.id, submitterBtcAddress: s.submitterBtcAddress, @@ -98,6 +139,8 @@ function buildWinner(s: BountySubmission, acceptedAt: string): BountyWinner { ...(s.contentUrl && { contentUrl: s.contentUrl }), message: s.message, acceptedAt, + ...(paidAt && { paidAt }), + ...(paidTxid && { paidTxid }), }; } @@ -107,7 +150,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..1cee7a2e 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).`, + maxWinners: `Optional. How many winners to accept (1..${MAX_WINNERS}, default 1). 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..c971a689 100644 --- a/lib/bounty/constants.ts +++ b/lib/bounty/constants.ts @@ -33,6 +33,9 @@ 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. Prevents accidental overflow. */ +export const MAX_WINNERS = 10; + /** 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..7d567520 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,132 @@ 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. + * + * 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`) * - * 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). + * When `winner_count + 1 = max_winners`, also sets `fully_accepted_at` so the + * pay-grace window can be computed without a join. * - * Returns `true` when the row was updated, `false` when the bounty was not in - * an acceptable state (race, already accepted, past the abandonment cutoff). + * 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 - .prepare( - `UPDATE bounties - SET paid_txid = ?, paid_at = ?, updated_at = ? - WHERE id = ? - AND accepted_at IS NOT NULL - AND paid_at IS NULL - AND cancelled_at IS NULL - AND accepted_at > ?` - ) - .bind(paidTxid, paidAt, paidAt, bountyId, payCutoff) - .run(); - return (result.meta?.changes ?? 0) > 0; + + try { + const [winnerResult, bountyResult] = await db.batch([ + 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), + + db + .prepare( + `UPDATE bounties + SET paid_count = paid_count + 1, updated_at = ? + WHERE id = ? + AND winner_count >= max_winners + AND paid_count < max_winners + AND cancelled_at IS NULL + AND fully_accepted_at > ?` + ) + .bind(paidAt, bountyId, payCutoff), + ]); + + return (winnerResult.meta?.changes ?? 0) > 0 && (bountyResult.meta?.changes ?? 0) > 0; + } catch (e) { + // Unique partial index on bounty_winners.paid_txid fired — txid reused. + if (String(e).includes("UNIQUE constraint failed")) return false; + throw e; + } } /** @@ -400,6 +503,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 // --------------------------------------------------------------------------- diff --git a/lib/bounty/id.ts b/lib/bounty/id.ts index e936703b..52d50819 100644 --- a/lib/bounty/id.ts +++ b/lib/bounty/id.ts @@ -15,3 +15,8 @@ export function generateBountyId(): string { export function generateSubmissionId(): string { return `${Date.now().toString(36)}${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; } + +/** Generate a new winner row id for the bounty_winners join table. */ +export function generateWinnerId(): string { + return `${Date.now().toString(36)}${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; +} 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..8c4e9c96 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,27 @@ 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.`, + format: `integer 1..${MAX_WINNERS}`, + example: "3", + }, + }); + } + } + if (b.tags !== undefined) { if (!Array.isArray(b.tags)) { errors.push({ @@ -301,6 +324,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 +484,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 +492,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 +512,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 +540,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..1563a92d --- /dev/null +++ b/migrations/023_bounty_multi_winner.sql @@ -0,0 +1,59 @@ +-- 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 +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; From 9d9af63cb95f13e90473ce7b6df24708c4163afa Mon Sep 17 00:00:00 2001 From: Iskander Date: Sun, 14 Jun 2026 23:01:00 +0200 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20remove=20MAX=5FWINNERS=3D10=20cap=20?= =?UTF-8?q?=E2=80=94=20poster=20decides=20winner=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No platform reason to cap at 10. MAX_WINNERS kept at 1000 as a malformed-input guard only. Validation hint updated accordingly. Co-Authored-By: Claude Sonnet 4.6 --- app/api/bounties/route.ts | 2 +- lib/bounty/constants.ts | 7 +++++-- lib/bounty/validation.ts | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/api/bounties/route.ts b/app/api/bounties/route.ts index 1cee7a2e..96989b91 100644 --- a/app/api/bounties/route.ts +++ b/app/api/bounties/route.ts @@ -80,7 +80,7 @@ function selfDoc(): NextResponse { title: `Short title (1..${TITLE_MAX} chars).`, description: `What needs to be done (1..${DESCRIPTION_MAX} chars, markdown allowed).`, rewardSats: `Promised total sBTC reward, integer > 0. For multi-winner: total pot split equally (e.g. 1500 sats / 3 winners = 500 sats each).`, - maxWinners: `Optional. How many winners to accept (1..${MAX_WINNERS}, default 1). Each winner receives rewardSats / maxWinners sats.`, + 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).", diff --git a/lib/bounty/constants.ts b/lib/bounty/constants.ts index c971a689..05623bb4 100644 --- a/lib/bounty/constants.ts +++ b/lib/bounty/constants.ts @@ -33,8 +33,11 @@ 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. Prevents accidental overflow. */ -export const MAX_WINNERS = 10; +/** + * 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/validation.ts b/lib/bounty/validation.ts index 8c4e9c96..0df61534 100644 --- a/lib/bounty/validation.ts +++ b/lib/bounty/validation.ts @@ -255,8 +255,8 @@ export function validateCreateBounty(body: unknown): 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.`, - format: `integer 1..${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", }, }); From 9a03e38c93589d5a67a820655ed19fc267071e90 Mon Sep 17 00:00:00 2001 From: Iskander Date: Sun, 14 Jun 2026 23:06:12 +0200 Subject: [PATCH 3/6] address arc0 review: propagate txid UNIQUE, batch fetch winners, dedup id generators - setWinnerPaid: remove try/catch so UNIQUE violations on paid_txid propagate to the /paid route's existing catch block, which already returns txid_already_redeemed (409). Previously the swallowed return-false sent the wrong error message to the caller. - getSubmissionsByIds: add batch IN (?) helper to d1-helpers and update buildWinnersArray to collect all missing winner submission IDs then fetch in one query. Eliminates the N+1 sequential getSubmission calls when winners submitted outside the first-page cache (listSubmissionsForBounty caps at 20). - id.ts: extract generateTimestampId() as shared private impl; all three public generators (generateBountyId, generateSubmissionId, generateWinnerId) are now aliases so the format is defined exactly once. Co-Authored-By: Claude Sonnet 4.6 --- app/api/bounties/[id]/route.ts | 31 ++++++++------- lib/bounty/d1-helpers.ts | 69 +++++++++++++++++++++------------- lib/bounty/id.ts | 15 ++++---- 3 files changed, 67 insertions(+), 48 deletions(-) diff --git a/app/api/bounties/[id]/route.ts b/app/api/bounties/[id]/route.ts index 69f210c0..781b3d6c 100644 --- a/app/api/bounties/[id]/route.ts +++ b/app/api/bounties/[id]/route.ts @@ -17,7 +17,7 @@ import { bountyStatus, buildExpectedMemo, getBounty, - getSubmission, + getSubmissionsByIds, getWinners, listSubmissionsForBounty, SBTC_CONTRACT_MAINNET, @@ -96,7 +96,7 @@ export async function GET( } async function buildWinnersArray( - db: Parameters[0], + db: Parameters[0], bounty: BountyRecord, winnerRows: BountyWinnerRow[], submissions: BountySubmission[] @@ -104,9 +104,9 @@ async function buildWinnersArray( if (winnerRows.length === 0) { // Pre-023 fallback: synthesize from legacy bounty fields if present. if (bounty.acceptedSubmissionId && bounty.acceptedAt) { - const sub = - submissions.find((s) => s.id === bounty.acceptedSubmissionId) ?? - (await getSubmission(db, bounty.acceptedSubmissionId)); + 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)]; } @@ -114,16 +114,21 @@ async function buildWinnersArray( return []; } - // Resolve submissions for each winner row. + // 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 result: BountyWinner[] = []; - for (const row of winnerRows) { - const sub = subMap.get(row.submissionId) ?? (await getSubmission(db, row.submissionId)); - if (sub) { - result.push(buildWinner(sub, row.acceptedAt, row.paidAt, row.paidTxid)); - } + 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 result; + + 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( diff --git a/lib/bounty/d1-helpers.ts b/lib/bounty/d1-helpers.ts index 7d567520..e51a9682 100644 --- a/lib/bounty/d1-helpers.ts +++ b/lib/bounty/d1-helpers.ts @@ -445,35 +445,32 @@ export async function setWinnerPaid( ): Promise { const payCutoff = new Date(Date.parse(paidAt) - PAY_GRACE_MS).toISOString(); - try { - const [winnerResult, bountyResult] = await db.batch([ - 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), + // 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, bountyResult] = await db.batch([ + 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), - db - .prepare( - `UPDATE bounties - SET paid_count = paid_count + 1, updated_at = ? - WHERE id = ? - AND winner_count >= max_winners - AND paid_count < max_winners - AND cancelled_at IS NULL - AND fully_accepted_at > ?` - ) - .bind(paidAt, bountyId, payCutoff), - ]); + db + .prepare( + `UPDATE bounties + SET paid_count = paid_count + 1, updated_at = ? + WHERE id = ? + AND winner_count >= max_winners + AND paid_count < max_winners + AND cancelled_at IS NULL + AND fully_accepted_at > ?` + ) + .bind(paidAt, bountyId, payCutoff), + ]); - return (winnerResult.meta?.changes ?? 0) > 0 && (bountyResult.meta?.changes ?? 0) > 0; - } catch (e) { - // Unique partial index on bounty_winners.paid_txid fired — txid reused. - if (String(e).includes("UNIQUE constraint failed")) return false; - throw e; - } + return (winnerResult.meta?.changes ?? 0) > 0 && (bountyResult.meta?.changes ?? 0) > 0; } /** @@ -654,6 +651,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 52d50819..d586670f 100644 --- a/lib/bounty/id.ts +++ b/lib/bounty/id.ts @@ -6,17 +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 function generateWinnerId(): string { - return `${Date.now().toString(36)}${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; -} +export const generateWinnerId = generateTimestampId; From 7abfa3ee544fde6cbee447bb33a992f40ae29223 Mon Sep 17 00:00:00 2001 From: Iskander Date: Sun, 14 Jun 2026 23:19:27 +0200 Subject: [PATCH 4/6] fix setWinnerPaid: sequential writes to prevent paid_count overcounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1 batches are atomic but statements are not causally linked. In the previous implementation, if two concurrent /paid requests both passed the winnerRow.paidAt pre-check, the second request's batch could see stmt1 get 0 changes (paid_at already set by the first) while stmt2 still satisfied paid_count < max_winners and incremented paid_count beyond the actual paid winner count — permanently locking the bounty in terminal "paid" state with an unpaid winner. Fix: split the batch into two sequential .run() calls. Check winnerResult.changes before issuing the bounty counter update. paid_count now only increments when this request actually claimed the winner row. UNIQUE violations on paid_txid still propagate directly to the route's catch(e) block (txid_already_redeemed). Co-Authored-By: Claude Sonnet 4.6 --- lib/bounty/d1-helpers.ts | 59 ++++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/lib/bounty/d1-helpers.ts b/lib/bounty/d1-helpers.ts index e51a9682..9e8e2f49 100644 --- a/lib/bounty/d1-helpers.ts +++ b/lib/bounty/d1-helpers.ts @@ -445,32 +445,49 @@ export async function setWinnerPaid( ): Promise { const payCutoff = new Date(Date.parse(paidAt) - PAY_GRACE_MS).toISOString(); + // 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, bountyResult] = await db.batch([ - 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), + 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(); - db - .prepare( - `UPDATE bounties - SET paid_count = paid_count + 1, updated_at = ? - WHERE id = ? - AND winner_count >= max_winners - AND paid_count < max_winners - AND cancelled_at IS NULL - AND fully_accepted_at > ?` - ) - .bind(paidAt, bountyId, payCutoff), - ]); + if ((winnerResult.meta?.changes ?? 0) === 0) { + return false; + } + + const bountyResult = await db + .prepare( + `UPDATE bounties + SET paid_count = paid_count + 1, updated_at = ? + WHERE id = ? + AND winner_count >= max_winners + AND paid_count < max_winners + AND cancelled_at IS NULL + AND fully_accepted_at > ?` + ) + .bind(paidAt, bountyId, payCutoff) + .run(); - return (winnerResult.meta?.changes ?? 0) > 0 && (bountyResult.meta?.changes ?? 0) > 0; + return (bountyResult.meta?.changes ?? 0) > 0; } /** From d1a88dd9659b77110623dfe2e5261c9496554bd9 Mon Sep 17 00:00:00 2001 From: Iskander Date: Sun, 14 Jun 2026 23:21:07 +0200 Subject: [PATCH 5/6] document migration vs runtime ID format discrepancy in bounty_winners backfill Migration backfill uses 'bw_' || lower(hex(randomblob(8))) (pure SQL, available at migration time) while generateWinnerId() produces a base36-ms-timestamp + 12-char UUID slice. Both are unique and opaque to callers; the comment explains the discrepancy so it doesn't look like a bug when debugging pre-023 rows. Co-Authored-By: Claude Sonnet 4.6 --- migrations/023_bounty_multi_winner.sql | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/migrations/023_bounty_multi_winner.sql b/migrations/023_bounty_multi_winner.sql index 1563a92d..150fb6a7 100644 --- a/migrations/023_bounty_multi_winner.sql +++ b/migrations/023_bounty_multi_winner.sql @@ -46,6 +46,13 @@ SET 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))), From f3c4e85200fdd9b2374a35b2013e8b2fac310f1e Mon Sep 17 00:00:00 2001 From: Iskander Date: Mon, 15 Jun 2026 06:45:54 +0200 Subject: [PATCH 6/6] address arc0 cycle 3: divisibility validation + grace-boundary inconsistency log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [suggestion] rewardSats divisibility check — add create-time validation that rejects rewardSats values not evenly divisible by maxWinners. Floor division silently strands the remainder sats with no recovery path. Error message includes the stranded amount and suggests the nearest divisible values. Also updated self-doc to document the divisibility requirement. [question] grace-boundary inconsistency in setWinnerPaid — when the pay grace window expires between the route's status check and the DB write, winnerResult can succeed (paid_at written) while bountyResult fails (fully_accepted_at guard). This leaves paid_count under-counted while the winner row shows paid. Added a console.error with full context and the manual reconciliation query so ops can detect and recover from this narrow race. Co-Authored-By: Claude Sonnet 4.6 --- app/api/bounties/route.ts | 2 +- lib/bounty/d1-helpers.ts | 24 +++++++++++++++++++++++- lib/bounty/validation.ts | 25 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/app/api/bounties/route.ts b/app/api/bounties/route.ts index 96989b91..20a46867 100644 --- a/app/api/bounties/route.ts +++ b/app/api/bounties/route.ts @@ -79,7 +79,7 @@ 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 total sBTC reward, integer > 0. For multi-winner: total pot split equally (e.g. 1500 sats / 3 winners = 500 sats each).`, + 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).", diff --git a/lib/bounty/d1-helpers.ts b/lib/bounty/d1-helpers.ts index 9e8e2f49..b2fc979e 100644 --- a/lib/bounty/d1-helpers.ts +++ b/lib/bounty/d1-helpers.ts @@ -487,7 +487,29 @@ export async function setWinnerPaid( .bind(paidAt, bountyId, payCutoff) .run(); - return (bountyResult.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; } /** diff --git a/lib/bounty/validation.ts b/lib/bounty/validation.ts index 0df61534..4eb2c5f7 100644 --- a/lib/bounty/validation.ts +++ b/lib/bounty/validation.ts @@ -263,6 +263,31 @@ export function validateCreateBounty(body: unknown): } } + // 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({