Files
pwap/web/supabase/migrations/archive/016_p2p_okx_security_upgrade.sql
T
pezkuwichain e18ba679be db: replace the migration history with a baseline of the live schema
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.
2026-07-31 18:49:59 -07:00

586 lines
18 KiB
PL/PgSQL

-- =====================================================
-- P2P OKX-LEVEL SECURITY UPGRADE
-- Migration: 016_p2p_okx_security_upgrade.sql
-- Date: 2026-01-29
-- =====================================================
--
-- This migration brings the P2P system to OKX-level security:
-- 1. Deposit verification restricted to service role only
-- 2. TX hash duplicate prevention (UNIQUE constraint)
-- 3. Auto-release removed (disputes instead)
-- 4. Enhanced escrow controls
--
-- =====================================================
-- 1. ADD UNIQUE CONSTRAINT ON TX HASH (Prevent Double-Credit)
-- =====================================================
-- Add unique constraint to prevent same TX being processed twice
DO $$
BEGIN
-- Check if table exists first
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'p2p_deposit_withdraw_requests') THEN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'p2p_deposit_withdraw_requests_tx_hash_unique'
) THEN
ALTER TABLE p2p_deposit_withdraw_requests
ADD CONSTRAINT p2p_deposit_withdraw_requests_tx_hash_unique
UNIQUE (blockchain_tx_hash)
DEFERRABLE INITIALLY DEFERRED;
END IF;
ELSE
RAISE NOTICE 'Table p2p_deposit_withdraw_requests does not exist. Please run migration 014 first.';
END IF;
END $$;
-- Index for faster duplicate checking (only if table exists)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'p2p_deposit_withdraw_requests') THEN
CREATE INDEX IF NOT EXISTS idx_deposit_withdraw_tx_hash
ON p2p_deposit_withdraw_requests(blockchain_tx_hash)
WHERE blockchain_tx_hash IS NOT NULL;
END IF;
END $$;
-- =====================================================
-- 2. SECURE PROCESS_DEPOSIT FUNCTION (Service Role Only)
-- =====================================================
-- Drop old function and recreate with service role check
DROP FUNCTION IF EXISTS process_deposit(UUID, TEXT, DECIMAL, TEXT, UUID);
CREATE OR REPLACE FUNCTION process_deposit(
p_user_id UUID,
p_token TEXT,
p_amount DECIMAL(20, 12),
p_tx_hash TEXT,
p_request_id UUID DEFAULT NULL
) RETURNS JSON AS $$
DECLARE
v_balance_before DECIMAL(20, 12) := 0;
v_existing_tx RECORD;
BEGIN
-- =====================================================
-- SECURITY CHECK: Only service role can call this function
-- This prevents users from crediting their own balance
-- =====================================================
IF current_setting('role', true) != 'service_role' AND
current_setting('request.jwt.claim.role', true) != 'service_role' THEN
RETURN json_build_object(
'success', false,
'error', 'UNAUTHORIZED: Only backend service can process deposits'
);
END IF;
-- =====================================================
-- DUPLICATE CHECK: Prevent same TX hash being processed twice
-- =====================================================
SELECT * INTO v_existing_tx
FROM p2p_deposit_withdraw_requests
WHERE blockchain_tx_hash = p_tx_hash
AND status = 'completed';
IF FOUND THEN
RETURN json_build_object(
'success', false,
'error', 'DUPLICATE: This transaction has already been processed',
'existing_request_id', v_existing_tx.id
);
END IF;
-- =====================================================
-- PROCESS DEPOSIT
-- =====================================================
-- Get current balance
SELECT available_balance INTO v_balance_before
FROM user_internal_balances
WHERE user_id = p_user_id AND token = p_token;
IF v_balance_before IS NULL THEN
v_balance_before := 0;
END IF;
-- Upsert balance
INSERT INTO user_internal_balances (
user_id, token, available_balance, total_deposited, last_deposit_at
) VALUES (
p_user_id, p_token, p_amount, p_amount, NOW()
)
ON CONFLICT (user_id, token)
DO UPDATE SET
available_balance = user_internal_balances.available_balance + p_amount,
total_deposited = user_internal_balances.total_deposited + p_amount,
last_deposit_at = NOW(),
updated_at = NOW();
-- Log the transaction
INSERT INTO p2p_balance_transactions (
user_id, token, transaction_type, amount,
balance_before, balance_after, reference_type, reference_id,
description
) VALUES (
p_user_id, p_token, 'deposit', p_amount,
v_balance_before, v_balance_before + p_amount, 'deposit_request', p_request_id,
'Verified deposit from blockchain TX: ' || p_tx_hash
);
-- Update request status if provided
IF p_request_id IS NOT NULL THEN
UPDATE p2p_deposit_withdraw_requests
SET
status = 'completed',
blockchain_tx_hash = p_tx_hash,
processed_at = NOW(),
updated_at = NOW()
WHERE id = p_request_id;
END IF;
RETURN json_build_object(
'success', true,
'deposited_amount', p_amount,
'new_balance', v_balance_before + p_amount,
'tx_hash', p_tx_hash
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Revoke execute from authenticated users (only service role)
REVOKE EXECUTE ON FUNCTION process_deposit FROM authenticated;
REVOKE EXECUTE ON FUNCTION process_deposit FROM anon;
-- =====================================================
-- 3. CREATE DEPOSIT REQUEST FUNCTION (User-facing)
-- =====================================================
-- Users submit deposit requests, backend verifies and credits
CREATE OR REPLACE FUNCTION submit_deposit_request(
p_token TEXT,
p_amount DECIMAL(20, 12),
p_tx_hash TEXT,
p_wallet_address TEXT
) RETURNS JSON AS $$
DECLARE
v_user_id UUID;
v_existing_request RECORD;
v_request_id UUID;
BEGIN
-- Get current user
v_user_id := auth.uid();
IF v_user_id IS NULL THEN
RETURN json_build_object('success', false, 'error', 'Not authenticated');
END IF;
-- Validate token
IF p_token NOT IN ('HEZ', 'PEZ') THEN
RETURN json_build_object('success', false, 'error', 'Invalid token');
END IF;
-- Validate amount
IF p_amount <= 0 THEN
RETURN json_build_object('success', false, 'error', 'Amount must be greater than 0');
END IF;
-- Check for existing request with same TX hash
SELECT * INTO v_existing_request
FROM p2p_deposit_withdraw_requests
WHERE blockchain_tx_hash = p_tx_hash;
IF FOUND THEN
RETURN json_build_object(
'success', false,
'error', 'A request with this transaction hash already exists',
'existing_status', v_existing_request.status
);
END IF;
-- Create deposit request (pending verification)
INSERT INTO p2p_deposit_withdraw_requests (
user_id,
request_type,
token,
amount,
wallet_address,
blockchain_tx_hash,
status
) VALUES (
v_user_id,
'deposit',
p_token,
p_amount,
p_wallet_address,
p_tx_hash,
'pending'
) RETURNING id INTO v_request_id;
RETURN json_build_object(
'success', true,
'request_id', v_request_id,
'status', 'pending',
'message', 'Deposit request submitted. Verification typically takes 1-5 minutes.'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Grant execute to authenticated users
GRANT EXECUTE ON FUNCTION submit_deposit_request TO authenticated;
-- =====================================================
-- 4. REMOVE AUTO-RELEASE, ADD DISPUTE TRIGGER
-- =====================================================
-- Replace cancel_expired_trades to NOT auto-release
CREATE OR REPLACE FUNCTION public.cancel_expired_trades()
RETURNS void AS $$
DECLARE
v_trade RECORD;
BEGIN
-- Cancel trades where buyer didn't pay in time
FOR v_trade IN
SELECT * FROM public.p2p_fiat_trades
WHERE status = 'pending'
AND payment_deadline < NOW()
LOOP
-- Update trade status
UPDATE public.p2p_fiat_trades
SET
status = 'cancelled',
cancelled_by = seller_id,
cancellation_reason = 'Payment deadline expired',
updated_at = NOW()
WHERE id = v_trade.id;
-- Refund escrow to seller (internal ledger)
PERFORM refund_escrow_internal(
v_trade.seller_id,
(SELECT token FROM p2p_fiat_offers WHERE id = v_trade.offer_id),
v_trade.crypto_amount,
'trade',
v_trade.id
);
-- Restore offer remaining amount
UPDATE public.p2p_fiat_offers
SET
remaining_amount = remaining_amount + v_trade.crypto_amount,
status = CASE
WHEN status = 'locked' THEN 'open'
ELSE status
END,
updated_at = NOW()
WHERE id = v_trade.offer_id;
-- Update reputation (penalty for buyer)
UPDATE public.p2p_reputation
SET
cancelled_trades = cancelled_trades + 1,
reputation_score = GREATEST(reputation_score - 10, 0),
updated_at = NOW()
WHERE user_id = v_trade.buyer_id;
END LOOP;
-- =====================================================
-- CRITICAL CHANGE: NO AUTO-RELEASE!
-- Instead of auto-releasing, escalate to dispute
-- =====================================================
FOR v_trade IN
SELECT * FROM public.p2p_fiat_trades
WHERE status = 'payment_sent'
AND confirmation_deadline < NOW()
AND status != 'disputed'
LOOP
-- Escalate to dispute instead of auto-releasing
UPDATE public.p2p_fiat_trades
SET
status = 'disputed',
dispute_reason = 'AUTO_ESCALATED: Seller did not confirm payment within time limit',
dispute_opened_at = NOW(),
dispute_opened_by = v_trade.buyer_id,
updated_at = NOW()
WHERE id = v_trade.id;
-- Log suspicious activity
INSERT INTO public.p2p_suspicious_activity (
user_id,
trade_id,
activity_type,
severity,
description,
metadata
) VALUES (
v_trade.seller_id,
v_trade.id,
'other',
'medium',
'Seller did not confirm payment within deadline - auto-escalated to dispute',
jsonb_build_object(
'buyer_id', v_trade.buyer_id,
'crypto_amount', v_trade.crypto_amount,
'payment_sent_at', v_trade.buyer_marked_paid_at,
'confirmation_deadline', v_trade.confirmation_deadline
)
);
-- Notify admins (in production, send push notification/email)
-- For now, just log
END LOOP;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =====================================================
-- 5. ADD DISPUTE COLUMNS IF NOT EXISTS
-- =====================================================
DO $$
BEGIN
-- Add dispute columns to trades table
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'p2p_fiat_trades' AND column_name = 'dispute_reason') THEN
ALTER TABLE p2p_fiat_trades ADD COLUMN dispute_reason TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'p2p_fiat_trades' AND column_name = 'dispute_opened_at') THEN
ALTER TABLE p2p_fiat_trades ADD COLUMN dispute_opened_at TIMESTAMPTZ;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'p2p_fiat_trades' AND column_name = 'dispute_opened_by') THEN
ALTER TABLE p2p_fiat_trades ADD COLUMN dispute_opened_by UUID REFERENCES auth.users(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'p2p_fiat_trades' AND column_name = 'dispute_resolved_at') THEN
ALTER TABLE p2p_fiat_trades ADD COLUMN dispute_resolved_at TIMESTAMPTZ;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'p2p_fiat_trades' AND column_name = 'dispute_resolved_by') THEN
ALTER TABLE p2p_fiat_trades ADD COLUMN dispute_resolved_by UUID REFERENCES auth.users(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'p2p_fiat_trades' AND column_name = 'dispute_resolution') THEN
ALTER TABLE p2p_fiat_trades ADD COLUMN dispute_resolution TEXT;
END IF;
END $$;
-- =====================================================
-- 6. ADMIN DISPUTE RESOLUTION FUNCTION
-- =====================================================
CREATE OR REPLACE FUNCTION resolve_p2p_dispute(
p_trade_id UUID,
p_resolution TEXT, -- 'release_to_buyer' or 'refund_to_seller'
p_resolution_notes TEXT DEFAULT NULL
) RETURNS JSON AS $$
DECLARE
v_admin_id UUID;
v_trade RECORD;
v_offer RECORD;
BEGIN
-- Get current user (must be admin)
v_admin_id := auth.uid();
-- Check admin role
IF NOT EXISTS (
SELECT 1 FROM profiles
WHERE id = v_admin_id
AND role IN ('admin', 'super_admin', 'moderator')
) THEN
RETURN json_build_object('success', false, 'error', 'Only admins can resolve disputes');
END IF;
-- Get trade
SELECT * INTO v_trade
FROM p2p_fiat_trades
WHERE id = p_trade_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN json_build_object('success', false, 'error', 'Trade not found');
END IF;
IF v_trade.status != 'disputed' THEN
RETURN json_build_object('success', false, 'error', 'Trade is not in disputed status');
END IF;
-- Get offer for token info
SELECT * INTO v_offer
FROM p2p_fiat_offers
WHERE id = v_trade.offer_id;
IF p_resolution = 'release_to_buyer' THEN
-- Release crypto to buyer
PERFORM release_escrow_internal(
v_trade.seller_id,
v_trade.buyer_id,
v_offer.token,
v_trade.crypto_amount,
'dispute_resolution',
p_trade_id
);
-- Update trade status
UPDATE p2p_fiat_trades
SET
status = 'completed',
completed_at = NOW(),
dispute_resolved_at = NOW(),
dispute_resolved_by = v_admin_id,
dispute_resolution = 'Released to buyer: ' || COALESCE(p_resolution_notes, ''),
updated_at = NOW()
WHERE id = p_trade_id;
-- Update reputations (seller gets penalty)
UPDATE p2p_reputation
SET
disputed_trades = disputed_trades + 1,
reputation_score = GREATEST(reputation_score - 20, 0),
updated_at = NOW()
WHERE user_id = v_trade.seller_id;
ELSIF p_resolution = 'refund_to_seller' THEN
-- Refund crypto to seller
PERFORM refund_escrow_internal(
v_trade.seller_id,
v_offer.token,
v_trade.crypto_amount,
'dispute_resolution',
p_trade_id
);
-- Restore offer if needed
UPDATE p2p_fiat_offers
SET
remaining_amount = remaining_amount + v_trade.crypto_amount,
status = CASE WHEN remaining_amount + v_trade.crypto_amount > 0 THEN 'open' ELSE status END,
updated_at = NOW()
WHERE id = v_trade.offer_id;
-- Update trade status
UPDATE p2p_fiat_trades
SET
status = 'refunded',
dispute_resolved_at = NOW(),
dispute_resolved_by = v_admin_id,
dispute_resolution = 'Refunded to seller: ' || COALESCE(p_resolution_notes, ''),
updated_at = NOW()
WHERE id = p_trade_id;
-- Update reputations (buyer gets penalty)
UPDATE p2p_reputation
SET
disputed_trades = disputed_trades + 1,
reputation_score = GREATEST(reputation_score - 20, 0),
updated_at = NOW()
WHERE user_id = v_trade.buyer_id;
ELSE
RETURN json_build_object('success', false, 'error', 'Invalid resolution type');
END IF;
-- Log the resolution
INSERT INTO p2p_audit_log (
user_id,
action,
entity_type,
entity_id,
details
) VALUES (
v_admin_id,
'dispute_resolved',
'trade',
p_trade_id,
jsonb_build_object(
'resolution', p_resolution,
'notes', p_resolution_notes,
'seller_id', v_trade.seller_id,
'buyer_id', v_trade.buyer_id,
'amount', v_trade.crypto_amount
)
);
RETURN json_build_object(
'success', true,
'resolution', p_resolution,
'trade_id', p_trade_id
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Grant to authenticated (function checks admin role internally)
GRANT EXECUTE ON FUNCTION resolve_p2p_dispute TO authenticated;
-- =====================================================
-- 7. ADD 'refunded' TO TRADE STATUS ENUM
-- =====================================================
DO $$
BEGIN
-- Check if constraint exists and update it
ALTER TABLE p2p_fiat_trades
DROP CONSTRAINT IF EXISTS p2p_fiat_trades_status_check;
ALTER TABLE p2p_fiat_trades
ADD CONSTRAINT p2p_fiat_trades_status_check
CHECK (status IN ('pending', 'payment_sent', 'completed', 'cancelled', 'disputed', 'refunded'));
EXCEPTION
WHEN others THEN
-- Constraint might not exist, ignore
NULL;
END $$;
-- =====================================================
-- 8. CREATE P2P_AUDIT_LOG TABLE IF NOT EXISTS
-- =====================================================
CREATE TABLE IF NOT EXISTS p2p_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id),
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID,
details JSONB DEFAULT '{}',
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_p2p_audit_log_user ON p2p_audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_p2p_audit_log_action ON p2p_audit_log(action);
CREATE INDEX IF NOT EXISTS idx_p2p_audit_log_entity ON p2p_audit_log(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_p2p_audit_log_created ON p2p_audit_log(created_at DESC);
ALTER TABLE p2p_audit_log ENABLE ROW LEVEL SECURITY;
-- Only admins can view audit log
CREATE POLICY "p2p_audit_log_admin_only" ON p2p_audit_log
FOR ALL USING (
EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND role IN ('admin', 'super_admin')
)
);
-- =====================================================
-- COMMENTS
-- =====================================================
COMMENT ON FUNCTION process_deposit IS
'OKX-LEVEL SECURITY: Process deposit - ONLY callable by service role (backend).
Users cannot credit their own balance. Backend must verify TX on-chain first.';
COMMENT ON FUNCTION submit_deposit_request IS
'User-facing function to submit deposit request. Creates pending request that
backend will verify and process using process_deposit().';
COMMENT ON FUNCTION cancel_expired_trades IS
'OKX-LEVEL SECURITY: NO AUTO-RELEASE. Expired confirmation trades are
escalated to dispute for admin review, not auto-released to buyer.';
COMMENT ON FUNCTION resolve_p2p_dispute IS
'Admin function to resolve P2P disputes. Can release to buyer or refund to seller.';