-- ============================================================================ -- Durable AdMob SSV receipt replay protection -- -- ad_reward_claims remains the ledger of rewards that were actually granted. -- Every otherwise valid, verified SSV transaction is consumed first in the -- service-only receipt ledger, including terminal cooldown/cap/tier outcomes. -- ============================================================================ BEGIN; CREATE TABLE public.ad_reward_receipts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), transaction_id text NOT NULL UNIQUE, user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, network text NOT NULL, placement text NOT NULL, ad_unit_id text NOT NULL, reward_tokens integer NOT NULL CHECK (reward_tokens > 0 AND reward_tokens <= 100), disposition text NOT NULL DEFAULT 'received' CHECK (disposition IN ( 'received', 'granted', 'unknown_user', 'ineligible_tier', 'daily_cap', 'cooldown', 'duplicate_claim' )), claim_id uuid REFERENCES public.ad_reward_claims(id) ON DELETE SET NULL, received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz ); -- Account deletion unlinks the subject while the provider transaction remains -- as the minimum durable replay barrier. COMMENT ON COLUMN public.ad_reward_receipts.user_id IS 'Verified callback subject; nulled on account deletion without deleting the transaction replay barrier.'; CREATE INDEX idx_ad_reward_receipts_user_received ON public.ad_reward_receipts(user_id, received_at DESC); ALTER TABLE public.ad_reward_receipts ENABLE ROW LEVEL SECURITY; -- No authenticated policies: verified callback receipts are service-only. -- Seed the replay barrier with every reward already granted before this -- migration. ad_reward_claims continues to be the authoritative grant ledger. INSERT INTO public.ad_reward_receipts ( transaction_id, user_id, network, placement, ad_unit_id, reward_tokens, disposition, claim_id, received_at, processed_at ) SELECT claim.transaction_id, claim.user_id, claim.network, claim.placement, claim.ad_unit_id, claim.reward_tokens, 'granted', claim.id, claim.verified_at, claim.verified_at FROM public.ad_reward_claims AS claim ON CONFLICT (transaction_id) DO NOTHING; CREATE OR REPLACE FUNCTION public.grant_verified_ad_reward( p_user_id uuid, p_network text, p_placement text, p_ad_unit_id text, p_transaction_id text, p_reward_tokens integer ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ DECLARE v_receipt_user_id uuid; v_receipt_id uuid; v_claim_id uuid; v_balance integer; v_result jsonb; BEGIN IF p_user_id IS NULL THEN RAISE EXCEPTION 'invalid_user'; END IF; IF p_transaction_id IS NULL OR length(trim(p_transaction_id)) < 8 OR length(trim(p_transaction_id)) > 128 THEN RAISE EXCEPTION 'invalid_transaction'; END IF; IF p_reward_tokens IS DISTINCT FROM 50 THEN RAISE EXCEPTION 'invalid_reward_amount'; END IF; IF length(trim(coalesce(p_network, ''))) NOT BETWEEN 1 AND 80 OR length(trim(coalesce(p_placement, ''))) NOT BETWEEN 1 AND 80 OR length(trim(coalesce(p_ad_unit_id, ''))) NOT BETWEEN 1 AND 80 THEN RAISE EXCEPTION 'invalid_reward_metadata'; END IF; -- Consume the provider transaction before looking up any mutable account -- state. A concurrent replay then blocks on the unique key and can only -- return duplicate after this transaction commits. INSERT INTO public.ad_reward_receipts ( transaction_id, network, placement, ad_unit_id, reward_tokens ) VALUES ( trim(p_transaction_id), trim(p_network), trim(p_placement), trim(p_ad_unit_id), p_reward_tokens ) ON CONFLICT (transaction_id) DO NOTHING RETURNING id INTO v_receipt_id; IF v_receipt_id IS NULL THEN RETURN jsonb_build_object('granted', false, 'reason', 'duplicate'); END IF; -- Serialize distinct transactions for the same subject. FOR KEY SHARE then -- closes the account-delete race until the receipt is linked or terminally -- recorded as unknown. PERFORM pg_advisory_xact_lock( pg_catalog.hashtextextended('d3ro:ad-reward:' || p_user_id::text, 0) ); SELECT id INTO v_receipt_user_id FROM auth.users WHERE id = p_user_id FOR KEY SHARE; IF v_receipt_user_id IS NULL THEN v_result := jsonb_build_object('granted', false, 'reason', 'unknown_user'); UPDATE public.ad_reward_receipts SET disposition = 'unknown_user', processed_at = now() WHERE id = v_receipt_id; RETURN v_result; END IF; UPDATE public.ad_reward_receipts SET user_id = v_receipt_user_id WHERE id = v_receipt_id; IF NOT EXISTS ( SELECT 1 FROM public.subscriptions WHERE user_id = p_user_id AND tier = 'free' AND coalesce(status, 'active') IN ('active', 'trialing') ) THEN v_result := jsonb_build_object('granted', false, 'reason', 'ineligible_tier'); UPDATE public.ad_reward_receipts SET disposition = 'ineligible_tier', processed_at = now() WHERE id = v_receipt_id; RETURN v_result; END IF; IF ( SELECT count(*) FROM public.ad_reward_claims WHERE user_id = p_user_id AND verified_at >= date_trunc('day', now()) ) >= 20 THEN v_result := jsonb_build_object('granted', false, 'reason', 'daily_cap'); UPDATE public.ad_reward_receipts SET disposition = 'daily_cap', processed_at = now() WHERE id = v_receipt_id; RETURN v_result; END IF; IF EXISTS ( SELECT 1 FROM public.ad_reward_claims WHERE user_id = p_user_id AND verified_at > now() - interval '15 seconds' ) THEN v_result := jsonb_build_object('granted', false, 'reason', 'cooldown'); UPDATE public.ad_reward_receipts SET disposition = 'cooldown', processed_at = now() WHERE id = v_receipt_id; RETURN v_result; END IF; INSERT INTO public.ad_reward_claims ( user_id, network, placement, ad_unit_id, transaction_id, reward_tokens ) VALUES ( p_user_id, trim(p_network), trim(p_placement), trim(p_ad_unit_id), trim(p_transaction_id), p_reward_tokens ) ON CONFLICT (transaction_id) DO NOTHING RETURNING id INTO v_claim_id; -- This can only occur for a legacy/direct claim that raced the receipt -- backfill. The new receipt remains consumed, so later retries stay denied. IF v_claim_id IS NULL THEN v_result := jsonb_build_object('granted', false, 'reason', 'duplicate'); UPDATE public.ad_reward_receipts SET disposition = 'duplicate_claim', processed_at = now() WHERE id = v_receipt_id; RETURN v_result; END IF; INSERT INTO public.subscriptions (user_id, tier, overage_credits, provider) VALUES (p_user_id, 'free', p_reward_tokens, 'none') ON CONFLICT (user_id) DO UPDATE SET overage_credits = public.subscriptions.overage_credits + EXCLUDED.overage_credits, updated_at = now() RETURNING overage_credits INTO v_balance; v_result := jsonb_build_object( 'granted', true, 'claim_id', v_claim_id, 'tokens_added', p_reward_tokens, 'balance', v_balance ); UPDATE public.ad_reward_receipts SET disposition = 'granted', claim_id = v_claim_id, processed_at = now() WHERE id = v_receipt_id; RETURN v_result; END; $$; REVOKE ALL ON TABLE public.ad_reward_receipts FROM PUBLIC, anon, authenticated; GRANT ALL ON TABLE public.ad_reward_receipts TO service_role; REVOKE ALL ON FUNCTION public.grant_verified_ad_reward( uuid, text, text, text, text, integer ) FROM PUBLIC, anon, authenticated; GRANT EXECUTE ON FUNCTION public.grant_verified_ad_reward( uuid, text, text, text, text, integer ) TO service_role; COMMIT;