Skip to content

Scope referral and cashback eligibility per identity, not per wallet #641

Description

@chibie

User Story

As Paycrest, I want referral rewards and BlockFest cashback to be granted once per verified person rather than once per wallet address, so that one human cannot multiply their rewards by holding several wallets.

This is the remaining prerequisite before Privy wallets could ever be exempted from phone/ID uniqueness the way injected wallets now are (#639, merged in #640).

Acceptance Criteria

  1. GIVEN several wallets sharing one verified phone or ID document
    WHEN they submit referral codes
    THEN only the first submission is accepted — the rest are refused as already-referred, exactly as a repeat submission on a single wallet is today

  2. GIVEN the same group of wallets
    WHEN they claim BlockFest cashback
    THEN MAX_CASHBACK_PER_WALLET and MAX_CLAIMS_PER_WALLET apply across the whole group, not per address

  3. GIVEN a wallet that is the only one on its identity
    WHEN it uses referrals or cashback
    THEN behaviour and copy are unchanged from today

  4. GIVEN a wallet with no verified identity (tier 0)
    WHEN eligibility is evaluated
    THEN it falls back to per-wallet scope, since there is no identity to pool on

Tech Details

  • Why now. feat(kyc): pool limits per identity, exempt injected wallets from uniqueness #640 moved spend limits to the verified identity via resolveIdentityScope() (app/lib/kyc-identity.ts), which is what made it safe to exempt injected wallets from the uniqueness indexes. That reasoning does not extend to Privy wallets, because the shared spend pool bounds spend only — rewards are still keyed per wallet address, and nothing pools them.
  • Referral submissionsupabase/migrations/add_referrals_referred_wallet_unique.sql enforces referrals_referred_wallet_address_unique on lower(referred_wallet_address), i.e. one referral per wallet. app/api/referral/submit/route.ts:143 inserts keyed on the wallet and :19-21,157 map the 23505 to "You have already used a referral code". N wallets on one identity means N accepted referrals.
  • Referral rewardsapp/api/referral/claim/route.ts keys qualification per wallet throughout: referrer_rewards_unlocked_at is read and written per wallet_address (:181-182, :197, :226-227), referral counting uses referrer_wallet_address (:204), and the KYC/volume qualification check uses :248.
  • BlockFest cashbackapp/api/blockfest/cashback/route.ts:20-22 defines MAX_CASHBACK_PER_TRANSACTION = 100, MAX_CASHBACK_PER_WALLET = 500, MAX_CLAIMS_PER_WALLET = 10, all enforced by counting rows where wallet_address is the caller (:256-281). blockfest_cashback_claims.wallet_address carries only a plain index (idx_cashback_claims_wallet) — no unique constraint — so the caps are purely application-side and per address.
  • Proposed approach. Reuse resolveIdentityScope() rather than inventing a second notion of identity. Replace .eq("wallet_address", caller) with .in("wallet_address", scope.wallets) in the eligibility counts, mirroring what app/api/kyc/transaction-summary/route.ts already does for spend. For referral submission, the wallet-level unique index is not sufficient on its own — either add an identity column to referrals maintained on insert, or gate on an identity-scoped pre-check plus keep the existing index as the per-wallet backstop.
  • Fail closed. resolveIdentityScope() throws rather than narrowing on a database error, for the same reason it does on the spend path: a narrower scope would silently grant extra rewards. Callers must not fall back to per-wallet on failure.
  • Concurrency. Reward granting needs the same treatment the spend path got in insert_swap_transaction_if_within_limit — without an identity-scoped lock, two sibling wallets can both pass an eligibility check and both be granted. Consider taking advisory locks on scope.identityKeys in sorted order, as that RPC does.

Notes/Assumptions

  • Injected wallets are not affected by the referral half: referrals are already disabled for them (app/components/MainPageContent.tsx:441 bails on isInjectedWallet, app/components/wallet-mobile-modal/WalletView.tsx:462 hides the UI). That asymmetry is precisely why exempting injected wallets from uniqueness in feat(kyc): pool limits per identity, exempt injected wallets from uniqueness #640 opened no rewards vector, while exempting Privy wallets would.
  • Identity matching inherits the known ceiling documented on Pool KYC limits per verified identity and exempt injected wallets from uniqueness #639: direct (one-hop) phone/ID matching, not transitive closure — deliberately, since closure would pool unrelated people who share a phone number.
  • This ticket does not propose exempting Privy wallets from uniqueness. It removes the blocker so that becomes a separate, deliberate decision.
  • Existing data may already contain multiple wallets per identity that have each claimed. A backfill/reconciliation policy for historical over-grants is an open question below.

Open Questions

  • Should historical over-grants be reconciled, or should identity scoping apply only from the deploy forward? Retroactive clawback of already-paid rewards is likely not worth it, but the counts (MAX_CLAIMS_PER_WALLET) would start from an already-consumed budget for affected users either way.
  • For referral submission, is an identity column on referrals (DB-enforced, stronger) preferred over an application pre-check (cheaper, racy without a lock)?
  • Do any other per-wallet grants need the same treatment? blockfest_participants and the play/fantasy tables were not audited as part of this scoping.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions