From fe1472b56007a214ddadb6251f2f83e1e62f1a93 Mon Sep 17 00:00:00 2001 From: Alex Nazaruk Date: Fri, 3 Jul 2026 11:09:21 +0200 Subject: [PATCH] fix(bounties): only allow claiming a FUNDED bounty (prevents payout for unfunded bounties) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/bounties/[id]/claim gated on status IN ('open','funded'). A bounty is created 'open' and only becomes 'funded' after the CoinPay funding webhook confirms the creator's payment. Accepting 'open' meant a bounty whose funding was never completed (creator abandoned/failed the CoinPay checkout) could still be claimed — and the claim path then prepares and broadcasts a real payout of bounty.reward_usd from the merchant web wallet to the claimer. Gate both the pre-check (line 23) and the atomic UPDATE ... WHERE (closing the TOCTOU) on status = 'funded' so a payout can only fire after funding is confirmed. --- apps/web/app/api/bounties/[id]/claim/route.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/app/api/bounties/[id]/claim/route.ts b/apps/web/app/api/bounties/[id]/claim/route.ts index 7d5c0e2..edadba4 100644 --- a/apps/web/app/api/bounties/[id]/claim/route.ts +++ b/apps/web/app/api/bounties/[id]/claim/route.ts @@ -20,8 +20,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const bounty = rows[0]; const bountyId = bounty.id; - if (!['open', 'funded'].includes(bounty.status)) { - return NextResponse.json({ error: `Bounty is already ${bounty.status}` }, { status: 409 }); + // Only a funded bounty may be claimed. A bounty stays 'open' until the CoinPay + // funding webhook flips it to 'funded'; accepting 'open' here let an unfunded + // bounty (abandoned/failed creator payment) be claimed and still trigger a real + // payout from the merchant wallet below. + if (bounty.status !== 'funded') { + return NextResponse.json({ error: `Bounty is not funded yet (status: ${bounty.status})` }, { status: 409 }); } if (bounty.creator_did === did) { return NextResponse.json({ error: 'Cannot claim your own bounty' }, { status: 400 }); @@ -35,7 +39,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: UPDATE bounties SET status = 'claimed', coupon_id = ${coupon_id}, claimer_did = ${did}, updated_at = ${new Date().toISOString()} - WHERE id = ${bountyId} AND status IN ('open', 'funded') + WHERE id = ${bountyId} AND status = 'funded' `; // Verify the claim succeeded (handles concurrent claim race)