mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 05:50:56 +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.
311 lines
9.4 KiB
PL/PgSQL
311 lines
9.4 KiB
PL/PgSQL
-- =====================================================
|
|
-- P2P FIAT SYSTEM - RPC FUNCTIONS
|
|
-- Production-grade stored procedures
|
|
-- =====================================================
|
|
|
|
-- =====================================================
|
|
-- INCREMENT ESCROW BALANCE
|
|
-- =====================================================
|
|
CREATE OR REPLACE FUNCTION public.increment_escrow_balance(
|
|
p_token TEXT,
|
|
p_amount NUMERIC
|
|
) RETURNS void AS $$
|
|
BEGIN
|
|
UPDATE public.platform_escrow_balance
|
|
SET
|
|
total_locked = total_locked + p_amount,
|
|
updated_at = NOW()
|
|
WHERE token = p_token;
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'Token % not found in escrow balance', p_token;
|
|
END IF;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- =====================================================
|
|
-- DECREMENT ESCROW BALANCE
|
|
-- =====================================================
|
|
CREATE OR REPLACE FUNCTION public.decrement_escrow_balance(
|
|
p_token TEXT,
|
|
p_amount NUMERIC
|
|
) RETURNS void AS $$
|
|
BEGIN
|
|
UPDATE public.platform_escrow_balance
|
|
SET
|
|
total_locked = total_locked - p_amount,
|
|
updated_at = NOW()
|
|
WHERE token = p_token;
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'Token % not found in escrow balance', p_token;
|
|
END IF;
|
|
|
|
-- Check for negative balance (should never happen)
|
|
IF (SELECT total_locked FROM public.platform_escrow_balance WHERE token = p_token) < 0 THEN
|
|
RAISE EXCEPTION 'Escrow balance would go negative for token %', p_token;
|
|
END IF;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- =====================================================
|
|
-- UPDATE P2P REPUTATION AFTER TRADE
|
|
-- =====================================================
|
|
CREATE OR REPLACE FUNCTION public.update_p2p_reputation(
|
|
p_seller_id UUID,
|
|
p_buyer_id UUID,
|
|
p_trade_id UUID
|
|
) RETURNS void AS $$
|
|
DECLARE
|
|
v_trade RECORD;
|
|
v_payment_time_minutes INT;
|
|
v_confirmation_time_minutes INT;
|
|
BEGIN
|
|
-- Get trade details
|
|
SELECT * INTO v_trade
|
|
FROM public.p2p_fiat_trades
|
|
WHERE id = p_trade_id;
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'Trade % not found', p_trade_id;
|
|
END IF;
|
|
|
|
-- Calculate timing metrics
|
|
IF v_trade.buyer_marked_paid_at IS NOT NULL THEN
|
|
v_payment_time_minutes := EXTRACT(EPOCH FROM (v_trade.buyer_marked_paid_at - v_trade.created_at)) / 60;
|
|
END IF;
|
|
|
|
IF v_trade.seller_confirmed_at IS NOT NULL AND v_trade.buyer_marked_paid_at IS NOT NULL THEN
|
|
v_confirmation_time_minutes := EXTRACT(EPOCH FROM (v_trade.seller_confirmed_at - v_trade.buyer_marked_paid_at)) / 60;
|
|
END IF;
|
|
|
|
-- Update seller reputation
|
|
INSERT INTO public.p2p_reputation (
|
|
user_id,
|
|
total_trades,
|
|
completed_trades,
|
|
total_as_seller,
|
|
reputation_score,
|
|
avg_confirmation_time_minutes,
|
|
last_trade_at,
|
|
first_trade_at
|
|
) VALUES (
|
|
p_seller_id,
|
|
1,
|
|
1,
|
|
1,
|
|
105, -- +5 bonus for first trade
|
|
v_confirmation_time_minutes,
|
|
NOW(),
|
|
NOW()
|
|
)
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
total_trades = p2p_reputation.total_trades + 1,
|
|
completed_trades = p2p_reputation.completed_trades + 1,
|
|
total_as_seller = p2p_reputation.total_as_seller + 1,
|
|
reputation_score = LEAST(p2p_reputation.reputation_score + 5, 1000),
|
|
avg_confirmation_time_minutes = CASE
|
|
WHEN p2p_reputation.avg_confirmation_time_minutes IS NULL THEN v_confirmation_time_minutes
|
|
ELSE (p2p_reputation.avg_confirmation_time_minutes + COALESCE(v_confirmation_time_minutes, 0)) / 2
|
|
END,
|
|
last_trade_at = NOW(),
|
|
updated_at = NOW();
|
|
|
|
-- Update buyer reputation
|
|
INSERT INTO public.p2p_reputation (
|
|
user_id,
|
|
total_trades,
|
|
completed_trades,
|
|
total_as_buyer,
|
|
reputation_score,
|
|
avg_payment_time_minutes,
|
|
last_trade_at,
|
|
first_trade_at
|
|
) VALUES (
|
|
p_buyer_id,
|
|
1,
|
|
1,
|
|
1,
|
|
105,
|
|
v_payment_time_minutes,
|
|
NOW(),
|
|
NOW()
|
|
)
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
total_trades = p2p_reputation.total_trades + 1,
|
|
completed_trades = p2p_reputation.completed_trades + 1,
|
|
total_as_buyer = p2p_reputation.total_as_buyer + 1,
|
|
reputation_score = LEAST(p2p_reputation.reputation_score + 5, 1000),
|
|
avg_payment_time_minutes = CASE
|
|
WHEN p2p_reputation.avg_payment_time_minutes IS NULL THEN v_payment_time_minutes
|
|
ELSE (p2p_reputation.avg_payment_time_minutes + COALESCE(v_payment_time_minutes, 0)) / 2
|
|
END,
|
|
last_trade_at = NOW(),
|
|
updated_at = NOW();
|
|
|
|
-- Update trust levels based on reputation score
|
|
UPDATE public.p2p_reputation
|
|
SET trust_level = CASE
|
|
WHEN reputation_score >= 900 THEN 'verified'
|
|
WHEN reputation_score >= 700 THEN 'advanced'
|
|
WHEN reputation_score >= 400 THEN 'intermediate'
|
|
WHEN reputation_score >= 100 THEN 'basic'
|
|
ELSE 'new'
|
|
END,
|
|
fast_trader = CASE
|
|
WHEN avg_payment_time_minutes < 15 AND avg_confirmation_time_minutes < 30 THEN true
|
|
ELSE false
|
|
END
|
|
WHERE user_id IN (p_seller_id, p_buyer_id);
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- =====================================================
|
|
-- CANCEL EXPIRED TRADES (Cron job function)
|
|
-- =====================================================
|
|
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;
|
|
|
|
-- 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;
|
|
|
|
-- Auto-release trades where seller didn't confirm in time
|
|
FOR v_trade IN
|
|
SELECT * FROM public.p2p_fiat_trades
|
|
WHERE status = 'payment_sent'
|
|
AND confirmation_deadline < NOW()
|
|
LOOP
|
|
-- Mark as completed (auto-release)
|
|
UPDATE public.p2p_fiat_trades
|
|
SET
|
|
seller_confirmed_at = NOW(),
|
|
status = 'completed',
|
|
completed_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = v_trade.id;
|
|
|
|
-- Note: Actual blockchain release must be done by backend service
|
|
-- This just marks the trade as ready for release
|
|
|
|
-- Update reputations
|
|
PERFORM public.update_p2p_reputation(v_trade.seller_id, v_trade.buyer_id, v_trade.id);
|
|
END LOOP;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- =====================================================
|
|
-- CANCEL EXPIRED OFFERS
|
|
-- =====================================================
|
|
CREATE OR REPLACE FUNCTION public.cancel_expired_offers()
|
|
RETURNS void AS $$
|
|
BEGIN
|
|
UPDATE public.p2p_fiat_offers
|
|
SET
|
|
status = 'cancelled',
|
|
updated_at = NOW()
|
|
WHERE status = 'open'
|
|
AND expires_at < NOW();
|
|
|
|
-- Note: Escrow refunds must be processed by backend service
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- =====================================================
|
|
-- GET PAYMENT METHOD DETAILS
|
|
-- =====================================================
|
|
CREATE OR REPLACE FUNCTION public.get_payment_method_details(
|
|
p_offer_id UUID,
|
|
p_requesting_user_id UUID
|
|
) RETURNS TABLE(
|
|
method_name TEXT,
|
|
payment_details JSONB
|
|
) AS $$
|
|
DECLARE
|
|
v_offer RECORD;
|
|
v_trade RECORD;
|
|
BEGIN
|
|
-- Get offer
|
|
SELECT * INTO v_offer
|
|
FROM public.p2p_fiat_offers
|
|
WHERE id = p_offer_id;
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'Offer not found';
|
|
END IF;
|
|
|
|
-- Check if user is involved in an active trade for this offer
|
|
SELECT * INTO v_trade
|
|
FROM public.p2p_fiat_trades
|
|
WHERE offer_id = p_offer_id
|
|
AND buyer_id = p_requesting_user_id
|
|
AND status IN ('pending', 'payment_sent')
|
|
LIMIT 1;
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'Unauthorized: You must have an active trade to view payment details';
|
|
END IF;
|
|
|
|
-- Return decrypted payment details
|
|
RETURN QUERY
|
|
SELECT
|
|
pm.method_name,
|
|
CASE
|
|
WHEN v_offer.payment_details_encrypted IS NOT NULL THEN
|
|
pgsodium.crypto_aead_aes256gcm_decrypt(
|
|
pgsodium.url_decode((v_offer.payment_details_encrypted->>'ciphertext')::TEXT),
|
|
NULL, -- No associated data used in this context
|
|
pgsodium.url_decode((v_offer.payment_details_encrypted->>'nonce')::TEXT),
|
|
pg_catalog.get_secret_variable('app.app_encryption_key')::BYTEA
|
|
)::TEXT::JSONB
|
|
ELSE
|
|
NULL
|
|
END AS payment_details
|
|
FROM public.payment_methods pm
|
|
WHERE pm.id = v_offer.payment_method_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- =====================================================
|
|
-- GRANT EXECUTE PERMISSIONS
|
|
-- =====================================================
|
|
GRANT EXECUTE ON FUNCTION public.increment_escrow_balance TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.decrement_escrow_balance TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.update_p2p_reputation TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.cancel_expired_trades TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.cancel_expired_offers TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.get_payment_method_details TO authenticated;
|