diff --git a/apps/web/app/api/bounties/[id]/claim/route.ts b/apps/web/app/api/bounties/[id]/claim/route.ts index b3a5185..a8e2047 100644 --- a/apps/web/app/api/bounties/[id]/claim/route.ts +++ b/apps/web/app/api/bounties/[id]/claim/route.ts @@ -62,16 +62,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } // Mark bounty as claimed — atomic WHERE prevents race conditions - await db.sql` + const claimed = await db.sql` UPDATE bounties SET status = 'claimed', coupon_id = ${coupon_id}, claimer_did = ${did}, updated_at = ${new Date().toISOString()} WHERE id = ${bountyId} AND status = 'funded' + RETURNING id `; - // Verify the claim succeeded (handles concurrent claim race) - const verify = await db.sql`SELECT claimer_did FROM bounties WHERE id = ${bountyId}`; - if (verify.length && verify[0].claimer_did !== did) { + if (!claimed.length) { return NextResponse.json({ error: 'Bounty was already claimed by another user' }, { status: 409 }); } diff --git a/test/bounty-claim-race.test.mjs b/test/bounty-claim-race.test.mjs new file mode 100644 index 0000000..535cf57 --- /dev/null +++ b/test/bounty-claim-race.test.mjs @@ -0,0 +1,21 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync( + new URL('../apps/web/app/api/bounties/[id]/claim/route.ts', import.meta.url), + 'utf8' +); + +test('a funded bounty is reserved atomically before payout', () => { + assert.match( + source, + /UPDATE bounties[\s\S]*WHERE id = \$\{bountyId\} AND status = 'funded'[\s\S]*RETURNING id/ + ); + assert.match(source, /if \(!claimed\.length\)/); + assert.doesNotMatch(source, /SELECT claimer_did FROM bounties/); + + const reservation = source.indexOf('const claimed = await db.sql'); + const payout = source.indexOf('prepare-tx'); + assert.ok(reservation >= 0 && reservation < payout, 'reservation must happen before payout'); +});