Skip to content

Bounty payout fires for a coupon that doesn't match the bounty's store (missing coupon↔bounty validation) #37

Description

@eltociear

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

  1. Note any existing coupon id (the migration seeds Amazon/Nike/Walmart coupons; or POST /api/coupons one for any store).
  2. Find a funded bounty for a different store.
  3. POST /api/bounties/{public_id}/claim with { "coupon_id": <that unrelated id> }.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions