Summary (a bounty payout fires for a coupon that has nothing to do with the bounty)
POST /api/bounties/[id]/claim only checks that the submitted coupon_id exists. It never checks that the coupon is actually for the store the bounty is asking about. Any pre-existing coupon id (e.g. 1, seeded/created for an unrelated store) satisfies the claim, and the route then broadcasts a real reward_usd payout from the merchant web wallet.
Details
In apps/web/app/api/bounties/[id]/claim/route.ts:
const coupons = await db.sql`SELECT * FROM coupons WHERE id = ${coupon_id}`; // line 30
if (!coupons.length) return NextResponse.json({ error: 'Coupon not found' }, { status: 404 }); // line 31
// ...straight to claim + payout, no relevance check:
await db.sql`
UPDATE bounties
SET status = 'claimed', coupon_id = ${coupon_id}, claimer_did = ${did}, ...
WHERE id = ${bountyId} AND status IN ('open', 'funded')
`; // line 34-39
// ... prepare-tx + broadcast pays bounty.reward_usd to the claimer // line 60-83
A bounty targets a store via store_id or free-text store_name (bounties table, apps/web/scripts/migrate.mjs:129), and every coupon has a store_id NOT NULL (apps/web/lib/schema.sql:17). The claim path compares neither. It also never checks that a coupon hasn't already been used to claim a different bounty.
Impact
- Payout for irrelevant work. A bounty created and funded for "a Nike coupon" can be claimed with an existing Amazon coupon (or literally any coupon id). The creator's escrowed
reward_usd is paid out for a coupon that does not fulfill the bounty.
- Coupon reuse / wallet drain. Because there is no "coupon already used" guard, one existing coupon can be used to claim every open/funded bounty in sequence, draining the merchant wallet.
This is separate from #35 (which is about open vs funded status) — it applies even to properly-funded bounties.
Repro
- Note any existing coupon id (the migration seeds Amazon/Nike/Walmart coupons; or
POST /api/coupons one for any store).
- Find a funded bounty for a different store.
POST /api/bounties/{public_id}/claim with { "coupon_id": <that unrelated id> }.
- Bounty flips to
claimed/paid and reward_usd is broadcast to your wallet.
Fix (PR opened)
Before marking the bounty claimed: (1) require the coupon's store to match the bounty's store_id (or store_name), and (2) reject a coupon already used to claim another bounty.
Summary (a bounty payout fires for a coupon that has nothing to do with the bounty)
POST /api/bounties/[id]/claimonly checks that the submittedcoupon_idexists. It never checks that the coupon is actually for the store the bounty is asking about. Any pre-existing coupon id (e.g.1, seeded/created for an unrelated store) satisfies the claim, and the route then broadcasts a realreward_usdpayout from the merchant web wallet.Details
In
apps/web/app/api/bounties/[id]/claim/route.ts:A bounty targets a store via
store_idor free-textstore_name(bountiestable,apps/web/scripts/migrate.mjs:129), and every coupon has astore_id NOT NULL(apps/web/lib/schema.sql:17). The claim path compares neither. It also never checks that a coupon hasn't already been used to claim a different bounty.Impact
reward_usdis paid out for a coupon that does not fulfill the bounty.This is separate from #35 (which is about
openvsfundedstatus) — it applies even to properly-funded bounties.Repro
POST /api/couponsone for any store).POST /api/bounties/{public_id}/claimwith{ "coupon_id": <that unrelated id> }.claimed/paidandreward_usdis broadcast to your wallet.Fix (PR opened)
Before marking the bounty claimed: (1) require the coupon's store to match the bounty's
store_id(orstore_name), and (2) reject a coupon already used to claim another bounty.