From 69b74c801bc2edf7e8914ad553da2e1e61c75c6f Mon Sep 17 00:00:00 2001 From: FuturMix Date: Sun, 14 Jun 2026 12:08:13 +0800 Subject: [PATCH] fix: prevent race condition in bounty claim with atomic status check The bounty claim handler uses a SELECT to check status, then a separate UPDATE to set it. Two concurrent requests can both pass the status check and claim the same bounty, potentially triggering duplicate payouts. Add a WHERE status IN ('open', 'funded') clause to the UPDATE so only the first concurrent claim succeeds at the database level, and verify the claimer_did after the UPDATE to detect and reject losing races. Co-Authored-By: Claude Opus 4.6 --- apps/web/app/api/bounties/[id]/claim/route.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/app/api/bounties/[id]/claim/route.ts b/apps/web/app/api/bounties/[id]/claim/route.ts index f6d311e..3a676f7 100644 --- a/apps/web/app/api/bounties/[id]/claim/route.ts +++ b/apps/web/app/api/bounties/[id]/claim/route.ts @@ -30,14 +30,20 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const coupons = await db.sql`SELECT * FROM coupons WHERE id = ${coupon_id}`; if (!coupons.length) return NextResponse.json({ error: 'Coupon not found' }, { status: 404 }); - // Mark bounty as claimed + // Mark bounty as claimed — atomic WHERE prevents race conditions await db.sql` UPDATE bounties SET status = 'claimed', coupon_id = ${coupon_id}, claimer_did = ${did}, updated_at = ${new Date().toISOString()} - WHERE id = ${bountyId} + WHERE id = ${bountyId} AND status IN ('open', 'funded') `; + // 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) { + return NextResponse.json({ error: 'Bounty was already claimed by another user' }, { status: 409 }); + } + // Attempt payout to claimer via web wallet // Docs: POST /api/web-wallet/:id/prepare-tx then /broadcast let payoutOk = false;