mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 12:31:34 +00:00
e18ba679be
The migrations did not describe this database, and could not be made to.
Three findings, in the order they surfaced:
* 25 functions declared across six migrations — all recorded as applied — did
not exist. Their tables did. Two were reached by the app, so merchant tier
upgrades and post-trade reputation updates had been quietly dead. This only
came to light because a user hit "Could not find the function
public.upsert_user_profile(...)" while toggling a notification setting.
* admin_roles has three conflicting definitions across the set and production
matches none of them. 001 says (id, user_id, role, granted_by, granted_at),
COMBINED says (user_id, role, created_at), production has
(id, user_id, role, permissions, created_at, updated_at).
* Applied to an empty database, five migrations fail. The legacy 0NN filenames
sort before the 14-digit timestamps they depend on — "013" < "20241117054600"
— so 013 runs before the migration creating the table it alters. The set
could never have been replayed from scratch.
So it could not be tested, could not rebuild the database, and did not match what
was running. Widening or patching it would have been dressing up a history that
was already fiction.
The baseline is a pg_dump of the live public schema, which matches production by
construction. Privileges are included deliberately: the REVOKEs on
lock_escrow_internal, release_escrow_internal, refund_escrow_internal and
request_withdraw are the 20260725030000 hardening, and dropping them would hand
fund movement back to anon.
Verified step by step against production before committing:
- applies to an empty database cleanly, after three real obstacles were fixed
(extensions live in the `extensions` schema, supabase_admin membership,
platform-level ALTER DEFAULT PRIVILEGES that cannot apply outside Supabase)
- produces 83 tables / 39 functions / 222 indexes / 360 policies — identical
counts to production
- recorded as applied in supabase_migrations without touching the 37 existing
rows, then dry-run confirmed "up to date — no pending migrations", so the
next deploy will not try to replay it over live tables
- drift check now reports "every declared function is present"; the known-gaps
list drops from 22 entries to zero
The old files move to migrations/archive/ rather than being deleted — they are
the only record of why parts of this look the way they do. Their README says
plainly not to run them, and why.
CI now applies migrations to an empty Postgres on every PR. That check was
impossible while the old set was the starting point; it is the thing that stops
this class of drift from being discovered by a user again.
457 lines
15 KiB
PL/PgSQL
457 lines
15 KiB
PL/PgSQL
-- =====================================================
|
|
-- P2P FRAUD PREVENTION SYSTEM
|
|
-- Auto-detection rules and triggers
|
|
-- =====================================================
|
|
|
|
-- =====================================================
|
|
-- USER FRAUD INDICATORS TABLE
|
|
-- =====================================================
|
|
CREATE TABLE IF NOT EXISTS public.p2p_user_fraud_indicators (
|
|
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
|
|
-- Trade statistics
|
|
cancel_rate DECIMAL(5,2) DEFAULT 0, -- Percentage
|
|
dispute_rate DECIMAL(5,2) DEFAULT 0, -- Percentage
|
|
avg_trade_amount DECIMAL(18,2) DEFAULT 0,
|
|
|
|
-- Recent activity
|
|
recent_cancellations_24h INT DEFAULT 0,
|
|
recent_disputes_7d INT DEFAULT 0,
|
|
trades_today INT DEFAULT 0,
|
|
volume_today DECIMAL(18,2) DEFAULT 0,
|
|
|
|
-- Risk assessment
|
|
risk_score INT DEFAULT 0 CHECK (risk_score BETWEEN 0 AND 100),
|
|
risk_level VARCHAR(10) DEFAULT 'low' CHECK (risk_level IN ('low', 'medium', 'high', 'critical')),
|
|
active_flags TEXT[] DEFAULT '{}',
|
|
|
|
-- Restrictions
|
|
is_blocked BOOLEAN DEFAULT FALSE,
|
|
blocked_reason TEXT,
|
|
blocked_at TIMESTAMPTZ,
|
|
blocked_until TIMESTAMPTZ,
|
|
requires_review BOOLEAN DEFAULT FALSE,
|
|
|
|
-- Cooldowns
|
|
last_cancellation_at TIMESTAMPTZ,
|
|
last_dispute_at TIMESTAMPTZ,
|
|
last_trade_at TIMESTAMPTZ,
|
|
|
|
-- Timestamps
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX idx_fraud_indicators_risk ON public.p2p_user_fraud_indicators(risk_score DESC);
|
|
CREATE INDEX idx_fraud_indicators_blocked ON public.p2p_user_fraud_indicators(is_blocked) WHERE is_blocked = true;
|
|
CREATE INDEX idx_fraud_indicators_review ON public.p2p_user_fraud_indicators(requires_review) WHERE requires_review = true;
|
|
|
|
-- Enable RLS
|
|
ALTER TABLE public.p2p_user_fraud_indicators ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Users can view their own indicators (limited)
|
|
CREATE POLICY "p2p_fraud_indicators_own_read" ON public.p2p_user_fraud_indicators
|
|
FOR SELECT USING (user_id = auth.uid());
|
|
|
|
-- Admins can view and update all
|
|
CREATE POLICY "p2p_fraud_indicators_admin" ON public.p2p_user_fraud_indicators
|
|
FOR ALL USING (
|
|
EXISTS (
|
|
SELECT 1 FROM public.admin_roles
|
|
WHERE user_id = auth.uid() AND role IN ('moderator', 'admin')
|
|
)
|
|
);
|
|
|
|
-- =====================================================
|
|
-- SUSPICIOUS ACTIVITY LOG
|
|
-- =====================================================
|
|
CREATE TABLE IF NOT EXISTS public.p2p_suspicious_activity (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES auth.users(id),
|
|
trade_id UUID REFERENCES public.p2p_fiat_trades(id),
|
|
|
|
-- Activity details
|
|
activity_type VARCHAR(50) NOT NULL CHECK (activity_type IN (
|
|
'high_cancel_rate', 'frequent_disputes', 'rapid_trading',
|
|
'unusual_amount', 'new_account_large_trade', 'payment_name_mismatch',
|
|
'suspected_multi_account', 'ip_anomaly', 'device_anomaly', 'other'
|
|
)),
|
|
severity VARCHAR(10) NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
|
|
description TEXT,
|
|
metadata JSONB DEFAULT '{}',
|
|
|
|
-- Resolution
|
|
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'reviewed', 'dismissed', 'actioned')),
|
|
reviewed_by UUID REFERENCES auth.users(id),
|
|
reviewed_at TIMESTAMPTZ,
|
|
action_taken TEXT,
|
|
|
|
-- Timestamps
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX idx_suspicious_activity_user ON public.p2p_suspicious_activity(user_id, created_at DESC);
|
|
CREATE INDEX idx_suspicious_activity_status ON public.p2p_suspicious_activity(status) WHERE status = 'pending';
|
|
CREATE INDEX idx_suspicious_activity_severity ON public.p2p_suspicious_activity(severity, created_at DESC);
|
|
|
|
-- Enable RLS
|
|
ALTER TABLE public.p2p_suspicious_activity ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Only admins can view
|
|
CREATE POLICY "p2p_suspicious_activity_admin" ON public.p2p_suspicious_activity
|
|
FOR ALL USING (
|
|
EXISTS (
|
|
SELECT 1 FROM public.admin_roles
|
|
WHERE user_id = auth.uid() AND role IN ('moderator', 'admin')
|
|
)
|
|
);
|
|
|
|
-- =====================================================
|
|
-- FUNCTION: Calculate User Risk Score
|
|
-- =====================================================
|
|
CREATE OR REPLACE FUNCTION public.calculate_user_risk_score(p_user_id UUID)
|
|
RETURNS TABLE(
|
|
risk_score INT,
|
|
risk_level TEXT,
|
|
flags TEXT[]
|
|
) AS $$
|
|
DECLARE
|
|
v_indicators RECORD;
|
|
v_score INT := 0;
|
|
v_flags TEXT[] := '{}';
|
|
BEGIN
|
|
-- Get current indicators
|
|
SELECT * INTO v_indicators
|
|
FROM public.p2p_user_fraud_indicators
|
|
WHERE user_id = p_user_id;
|
|
|
|
-- If no record exists, create one
|
|
IF NOT FOUND THEN
|
|
INSERT INTO public.p2p_user_fraud_indicators (user_id)
|
|
VALUES (p_user_id)
|
|
RETURNING * INTO v_indicators;
|
|
END IF;
|
|
|
|
-- Calculate from reputation data
|
|
SELECT
|
|
COALESCE(
|
|
CASE WHEN total_trades > 0
|
|
THEN (cancelled_trades::DECIMAL / total_trades * 100)
|
|
ELSE 0
|
|
END, 0),
|
|
COALESCE(
|
|
CASE WHEN total_trades > 0
|
|
THEN (disputed_trades::DECIMAL / total_trades * 100)
|
|
ELSE 0
|
|
END, 0)
|
|
INTO v_indicators.cancel_rate, v_indicators.dispute_rate
|
|
FROM public.p2p_reputation
|
|
WHERE user_id = p_user_id;
|
|
|
|
-- Apply rules and calculate score
|
|
-- Rule 1: High cancel rate (>30%)
|
|
IF v_indicators.cancel_rate > 30 THEN
|
|
v_score := v_score + 25;
|
|
v_flags := array_append(v_flags, 'High Cancellation Rate');
|
|
END IF;
|
|
|
|
-- Rule 2: High dispute rate (>20%)
|
|
IF v_indicators.dispute_rate > 20 THEN
|
|
v_score := v_score + 30;
|
|
v_flags := array_append(v_flags, 'Frequent Disputes');
|
|
END IF;
|
|
|
|
-- Rule 3: Multiple recent cancellations (>3 in 24h)
|
|
IF v_indicators.recent_cancellations_24h > 3 THEN
|
|
v_score := v_score + 35;
|
|
v_flags := array_append(v_flags, 'Multiple Recent Cancellations');
|
|
END IF;
|
|
|
|
-- Rule 4: Recent disputes (>2 in 7d)
|
|
IF v_indicators.recent_disputes_7d > 2 THEN
|
|
v_score := v_score + 25;
|
|
v_flags := array_append(v_flags, 'Recent Disputes');
|
|
END IF;
|
|
|
|
-- Cap score at 100
|
|
v_score := LEAST(v_score, 100);
|
|
|
|
-- Determine risk level
|
|
risk_level := CASE
|
|
WHEN v_score >= 95 THEN 'critical'
|
|
WHEN v_score >= 80 THEN 'high'
|
|
WHEN v_score >= 50 THEN 'medium'
|
|
ELSE 'low'
|
|
END;
|
|
|
|
-- Update indicators table
|
|
UPDATE public.p2p_user_fraud_indicators
|
|
SET
|
|
risk_score = v_score,
|
|
risk_level = risk_level,
|
|
active_flags = v_flags,
|
|
is_blocked = (v_score >= 95),
|
|
requires_review = (v_score >= 80),
|
|
updated_at = NOW()
|
|
WHERE user_id = p_user_id;
|
|
|
|
risk_score := v_score;
|
|
flags := v_flags;
|
|
|
|
RETURN NEXT;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- =====================================================
|
|
-- FUNCTION: Check Trade Allowed
|
|
-- =====================================================
|
|
CREATE OR REPLACE FUNCTION public.check_trade_allowed(
|
|
p_user_id UUID,
|
|
p_trade_amount DECIMAL
|
|
) RETURNS TABLE(
|
|
allowed BOOLEAN,
|
|
reason TEXT
|
|
) AS $$
|
|
DECLARE
|
|
v_indicators RECORD;
|
|
v_reputation RECORD;
|
|
v_limits RECORD;
|
|
v_cooldown_ms BIGINT;
|
|
BEGIN
|
|
-- Get fraud indicators
|
|
SELECT * INTO v_indicators
|
|
FROM public.p2p_user_fraud_indicators
|
|
WHERE user_id = p_user_id;
|
|
|
|
-- Check if blocked
|
|
IF v_indicators IS NOT NULL AND v_indicators.is_blocked THEN
|
|
allowed := FALSE;
|
|
reason := COALESCE(v_indicators.blocked_reason, 'Your account is temporarily restricted from trading.');
|
|
RETURN NEXT;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Check cooldown after cancellation (5 minutes)
|
|
IF v_indicators IS NOT NULL AND v_indicators.last_cancellation_at IS NOT NULL THEN
|
|
v_cooldown_ms := EXTRACT(EPOCH FROM (NOW() - v_indicators.last_cancellation_at)) * 1000;
|
|
IF v_cooldown_ms < 300000 THEN -- 5 minutes
|
|
allowed := FALSE;
|
|
reason := format('Please wait %s seconds before creating a new trade.', ((300000 - v_cooldown_ms) / 1000)::INT);
|
|
RETURN NEXT;
|
|
RETURN;
|
|
END IF;
|
|
END IF;
|
|
|
|
-- Check cooldown after dispute (24 hours)
|
|
IF v_indicators IS NOT NULL AND v_indicators.last_dispute_at IS NOT NULL THEN
|
|
v_cooldown_ms := EXTRACT(EPOCH FROM (NOW() - v_indicators.last_dispute_at)) * 1000;
|
|
IF v_cooldown_ms < 86400000 THEN -- 24 hours
|
|
allowed := FALSE;
|
|
reason := 'Trading is restricted for 24 hours after a dispute.';
|
|
RETURN NEXT;
|
|
RETURN;
|
|
END IF;
|
|
END IF;
|
|
|
|
-- Get reputation for trust level
|
|
SELECT * INTO v_reputation
|
|
FROM public.p2p_reputation
|
|
WHERE user_id = p_user_id;
|
|
|
|
-- Define limits based on trust level
|
|
SELECT * INTO v_limits FROM (
|
|
SELECT
|
|
CASE COALESCE(v_reputation.trust_level, 'new')
|
|
WHEN 'verified' THEN 50000
|
|
WHEN 'advanced' THEN 10000
|
|
WHEN 'intermediate' THEN 2000
|
|
WHEN 'basic' THEN 500
|
|
ELSE 100
|
|
END as max_trade,
|
|
CASE COALESCE(v_reputation.trust_level, 'new')
|
|
WHEN 'verified' THEN 50
|
|
WHEN 'advanced' THEN 20
|
|
WHEN 'intermediate' THEN 10
|
|
WHEN 'basic' THEN 5
|
|
ELSE 3
|
|
END as max_daily_trades,
|
|
CASE COALESCE(v_reputation.trust_level, 'new')
|
|
WHEN 'verified' THEN 100000
|
|
WHEN 'advanced' THEN 25000
|
|
WHEN 'intermediate' THEN 5000
|
|
WHEN 'basic' THEN 1000
|
|
ELSE 200
|
|
END as max_daily_volume
|
|
) limits;
|
|
|
|
-- Check trade amount limit
|
|
IF p_trade_amount > v_limits.max_trade THEN
|
|
allowed := FALSE;
|
|
reason := format('Trade amount exceeds your limit of $%s. Complete more trades to increase your limit.', v_limits.max_trade);
|
|
RETURN NEXT;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Check daily trade count
|
|
IF v_indicators IS NOT NULL AND v_indicators.trades_today >= v_limits.max_daily_trades THEN
|
|
allowed := FALSE;
|
|
reason := format('You have reached your daily limit of %s trades.', v_limits.max_daily_trades);
|
|
RETURN NEXT;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Check daily volume
|
|
IF v_indicators IS NOT NULL AND (v_indicators.volume_today + p_trade_amount) > v_limits.max_daily_volume THEN
|
|
allowed := FALSE;
|
|
reason := format('This trade would exceed your daily volume limit of $%s.', v_limits.max_daily_volume);
|
|
RETURN NEXT;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- All checks passed
|
|
allowed := TRUE;
|
|
reason := NULL;
|
|
RETURN NEXT;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- =====================================================
|
|
-- FUNCTION: Log Suspicious Activity
|
|
-- =====================================================
|
|
CREATE OR REPLACE FUNCTION public.log_suspicious_activity(
|
|
p_user_id UUID,
|
|
p_trade_id UUID,
|
|
p_activity_type TEXT,
|
|
p_severity TEXT,
|
|
p_description TEXT DEFAULT NULL,
|
|
p_metadata JSONB DEFAULT '{}'
|
|
) RETURNS UUID AS $$
|
|
DECLARE
|
|
v_activity_id UUID;
|
|
BEGIN
|
|
INSERT INTO public.p2p_suspicious_activity (
|
|
user_id, trade_id, activity_type, severity, description, metadata
|
|
) VALUES (
|
|
p_user_id, p_trade_id, p_activity_type, p_severity, p_description, p_metadata
|
|
)
|
|
RETURNING id INTO v_activity_id;
|
|
|
|
-- Auto-recalculate risk score
|
|
PERFORM public.calculate_user_risk_score(p_user_id);
|
|
|
|
RETURN v_activity_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- =====================================================
|
|
-- TRIGGER: Update Fraud Indicators on Trade Completion
|
|
-- =====================================================
|
|
CREATE OR REPLACE FUNCTION update_fraud_indicators_on_trade()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
-- On trade completion
|
|
IF NEW.status = 'completed' AND (OLD.status IS NULL OR OLD.status != 'completed') THEN
|
|
-- Update buyer indicators
|
|
INSERT INTO public.p2p_user_fraud_indicators (user_id, trades_today, volume_today, last_trade_at)
|
|
VALUES (NEW.buyer_id, 1, NEW.fiat_amount, NOW())
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
trades_today = p2p_user_fraud_indicators.trades_today + 1,
|
|
volume_today = p2p_user_fraud_indicators.volume_today + NEW.fiat_amount,
|
|
last_trade_at = NOW(),
|
|
updated_at = NOW();
|
|
|
|
-- Update seller indicators
|
|
INSERT INTO public.p2p_user_fraud_indicators (user_id, trades_today, volume_today, last_trade_at)
|
|
VALUES (NEW.seller_id, 1, NEW.fiat_amount, NOW())
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
trades_today = p2p_user_fraud_indicators.trades_today + 1,
|
|
volume_today = p2p_user_fraud_indicators.volume_today + NEW.fiat_amount,
|
|
last_trade_at = NOW(),
|
|
updated_at = NOW();
|
|
END IF;
|
|
|
|
-- On trade cancellation
|
|
IF NEW.status = 'cancelled' AND (OLD.status IS NULL OR OLD.status != 'cancelled') THEN
|
|
IF NEW.cancelled_by IS NOT NULL THEN
|
|
UPDATE public.p2p_user_fraud_indicators
|
|
SET
|
|
recent_cancellations_24h = recent_cancellations_24h + 1,
|
|
last_cancellation_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE user_id = NEW.cancelled_by;
|
|
|
|
-- Recalculate risk score
|
|
PERFORM public.calculate_user_risk_score(NEW.cancelled_by);
|
|
END IF;
|
|
END IF;
|
|
|
|
-- On dispute
|
|
IF NEW.status = 'disputed' AND (OLD.status IS NULL OR OLD.status != 'disputed') THEN
|
|
-- Update both parties
|
|
UPDATE public.p2p_user_fraud_indicators
|
|
SET
|
|
recent_disputes_7d = recent_disputes_7d + 1,
|
|
last_dispute_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE user_id IN (NEW.buyer_id, NEW.seller_id);
|
|
|
|
-- Recalculate risk scores
|
|
PERFORM public.calculate_user_risk_score(NEW.buyer_id);
|
|
PERFORM public.calculate_user_risk_score(NEW.seller_id);
|
|
END IF;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
CREATE TRIGGER trigger_update_fraud_indicators
|
|
AFTER UPDATE ON public.p2p_fiat_trades
|
|
FOR EACH ROW EXECUTE FUNCTION update_fraud_indicators_on_trade();
|
|
|
|
-- =====================================================
|
|
-- SCHEDULED JOB: Reset Daily Counters
|
|
-- (Run at midnight UTC via pg_cron or external scheduler)
|
|
-- =====================================================
|
|
CREATE OR REPLACE FUNCTION public.reset_daily_fraud_counters()
|
|
RETURNS void AS $$
|
|
BEGIN
|
|
UPDATE public.p2p_user_fraud_indicators
|
|
SET
|
|
trades_today = 0,
|
|
volume_today = 0,
|
|
updated_at = NOW();
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- =====================================================
|
|
-- SCHEDULED JOB: Reset Weekly Counters
|
|
-- =====================================================
|
|
CREATE OR REPLACE FUNCTION public.reset_weekly_fraud_counters()
|
|
RETURNS void AS $$
|
|
BEGIN
|
|
UPDATE public.p2p_user_fraud_indicators
|
|
SET
|
|
recent_disputes_7d = 0,
|
|
updated_at = NOW();
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- =====================================================
|
|
-- GRANT PERMISSIONS
|
|
-- =====================================================
|
|
GRANT EXECUTE ON FUNCTION public.calculate_user_risk_score TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.check_trade_allowed TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.log_suspicious_activity TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.reset_daily_fraud_counters TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.reset_weekly_fraud_counters TO authenticated;
|
|
|
|
-- =====================================================
|
|
-- COMMENTS
|
|
-- =====================================================
|
|
COMMENT ON TABLE public.p2p_user_fraud_indicators IS 'Tracks fraud indicators and risk scores for P2P users';
|
|
COMMENT ON TABLE public.p2p_suspicious_activity IS 'Log of suspicious activities detected by the fraud system';
|
|
COMMENT ON FUNCTION public.calculate_user_risk_score IS 'Calculate and update user risk score based on trading behavior';
|
|
COMMENT ON FUNCTION public.check_trade_allowed IS 'Check if a user is allowed to make a trade based on limits and restrictions';
|