mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 13:41:32 +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.
277 lines
8.0 KiB
PL/PgSQL
277 lines
8.0 KiB
PL/PgSQL
-- =====================================================
|
|
-- P2P ATOMIC ESCROW & PLATFORM WALLET SYSTEM
|
|
-- Migration: 013_p2p_atomic_escrow.sql
|
|
-- =====================================================
|
|
|
|
-- 1. Platform escrow wallet tracking table
|
|
CREATE TABLE IF NOT EXISTS p2p_platform_escrow (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
offer_id UUID NOT NULL REFERENCES p2p_fiat_offers(id) ON DELETE CASCADE,
|
|
seller_id UUID NOT NULL REFERENCES auth.users(id),
|
|
seller_wallet TEXT NOT NULL,
|
|
token TEXT NOT NULL,
|
|
amount DECIMAL(20, 12) NOT NULL,
|
|
locked_at TIMESTAMPTZ DEFAULT NOW(),
|
|
released_at TIMESTAMPTZ,
|
|
released_to TEXT, -- wallet address
|
|
release_reason TEXT, -- 'trade_complete', 'trade_cancelled', 'trade_expired', 'refund'
|
|
blockchain_tx_lock TEXT, -- tx hash for lock transfer
|
|
blockchain_tx_release TEXT, -- tx hash for release transfer
|
|
status TEXT NOT NULL DEFAULT 'locked' CHECK (status IN ('locked', 'released', 'pending_release')),
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Index for quick lookups
|
|
CREATE INDEX IF NOT EXISTS idx_platform_escrow_offer ON p2p_platform_escrow(offer_id);
|
|
CREATE INDEX IF NOT EXISTS idx_platform_escrow_seller ON p2p_platform_escrow(seller_id);
|
|
CREATE INDEX IF NOT EXISTS idx_platform_escrow_status ON p2p_platform_escrow(status);
|
|
|
|
-- 2. Atomic function to accept offer (prevents race condition)
|
|
CREATE OR REPLACE FUNCTION accept_p2p_offer(
|
|
p_offer_id UUID,
|
|
p_buyer_id UUID,
|
|
p_buyer_wallet TEXT,
|
|
p_amount DECIMAL(20, 12)
|
|
) RETURNS JSON AS $$
|
|
DECLARE
|
|
v_offer RECORD;
|
|
v_trade_id UUID;
|
|
v_payment_deadline TIMESTAMPTZ;
|
|
v_fiat_amount DECIMAL(20, 2);
|
|
BEGIN
|
|
-- Lock the offer row for update (prevents concurrent modifications)
|
|
SELECT * INTO v_offer
|
|
FROM p2p_fiat_offers
|
|
WHERE id = p_offer_id
|
|
FOR UPDATE;
|
|
|
|
-- Validation checks
|
|
IF v_offer IS NULL THEN
|
|
RETURN json_build_object('success', false, 'error', 'Offer not found');
|
|
END IF;
|
|
|
|
IF v_offer.status != 'open' THEN
|
|
RETURN json_build_object('success', false, 'error', 'Offer is not available');
|
|
END IF;
|
|
|
|
IF v_offer.seller_id = p_buyer_id THEN
|
|
RETURN json_build_object('success', false, 'error', 'Cannot buy from your own offer');
|
|
END IF;
|
|
|
|
IF p_amount > v_offer.remaining_amount THEN
|
|
RETURN json_build_object('success', false, 'error', 'Insufficient remaining amount. Available: ' || v_offer.remaining_amount);
|
|
END IF;
|
|
|
|
IF v_offer.min_order_amount IS NOT NULL AND p_amount < v_offer.min_order_amount THEN
|
|
RETURN json_build_object('success', false, 'error', 'Minimum order: ' || v_offer.min_order_amount || ' ' || v_offer.token);
|
|
END IF;
|
|
|
|
IF v_offer.max_order_amount IS NOT NULL AND p_amount > v_offer.max_order_amount THEN
|
|
RETURN json_build_object('success', false, 'error', 'Maximum order: ' || v_offer.max_order_amount || ' ' || v_offer.token);
|
|
END IF;
|
|
|
|
-- Calculate fiat amount
|
|
v_fiat_amount := (p_amount / v_offer.amount_crypto) * v_offer.fiat_amount;
|
|
v_payment_deadline := NOW() + (v_offer.time_limit_minutes || ' minutes')::INTERVAL;
|
|
|
|
-- Create trade
|
|
INSERT INTO p2p_fiat_trades (
|
|
offer_id,
|
|
seller_id,
|
|
buyer_id,
|
|
buyer_wallet,
|
|
crypto_amount,
|
|
fiat_amount,
|
|
price_per_unit,
|
|
escrow_locked_amount,
|
|
escrow_locked_at,
|
|
status,
|
|
payment_deadline
|
|
) VALUES (
|
|
p_offer_id,
|
|
v_offer.seller_id,
|
|
p_buyer_id,
|
|
p_buyer_wallet,
|
|
p_amount,
|
|
v_fiat_amount,
|
|
v_offer.price_per_unit,
|
|
p_amount,
|
|
NOW(),
|
|
'pending',
|
|
v_payment_deadline
|
|
) RETURNING id INTO v_trade_id;
|
|
|
|
-- Atomically update remaining amount
|
|
UPDATE p2p_fiat_offers
|
|
SET
|
|
remaining_amount = remaining_amount - p_amount,
|
|
status = CASE
|
|
WHEN remaining_amount - p_amount = 0 THEN 'locked'
|
|
ELSE 'open'
|
|
END,
|
|
updated_at = NOW()
|
|
WHERE id = p_offer_id;
|
|
|
|
RETURN json_build_object(
|
|
'success', true,
|
|
'trade_id', v_trade_id,
|
|
'crypto_amount', p_amount,
|
|
'fiat_amount', v_fiat_amount,
|
|
'payment_deadline', v_payment_deadline
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 3. Function to release escrow on trade completion
|
|
CREATE OR REPLACE FUNCTION complete_p2p_trade(
|
|
p_trade_id UUID,
|
|
p_seller_id UUID
|
|
) RETURNS JSON AS $$
|
|
DECLARE
|
|
v_trade RECORD;
|
|
BEGIN
|
|
-- Lock trade row
|
|
SELECT * INTO v_trade
|
|
FROM p2p_fiat_trades
|
|
WHERE id = p_trade_id
|
|
FOR UPDATE;
|
|
|
|
IF v_trade IS NULL THEN
|
|
RETURN json_build_object('success', false, 'error', 'Trade not found');
|
|
END IF;
|
|
|
|
IF v_trade.seller_id != p_seller_id THEN
|
|
RETURN json_build_object('success', false, 'error', 'Only seller can confirm receipt');
|
|
END IF;
|
|
|
|
IF v_trade.status != 'payment_sent' THEN
|
|
RETURN json_build_object('success', false, 'error', 'Payment not marked as sent yet');
|
|
END IF;
|
|
|
|
-- Update trade status
|
|
UPDATE p2p_fiat_trades
|
|
SET
|
|
status = 'completed',
|
|
completed_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = p_trade_id;
|
|
|
|
-- Update escrow status (will be released by backend)
|
|
UPDATE p2p_platform_escrow
|
|
SET
|
|
status = 'pending_release',
|
|
released_to = v_trade.buyer_wallet,
|
|
release_reason = 'trade_complete',
|
|
updated_at = NOW()
|
|
WHERE offer_id = v_trade.offer_id
|
|
AND status = 'locked';
|
|
|
|
RETURN json_build_object(
|
|
'success', true,
|
|
'buyer_wallet', v_trade.buyer_wallet,
|
|
'amount', v_trade.crypto_amount,
|
|
'token', (SELECT token FROM p2p_fiat_offers WHERE id = v_trade.offer_id)
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 4. Function to cancel/expire trade (return to seller)
|
|
CREATE OR REPLACE FUNCTION cancel_p2p_trade(
|
|
p_trade_id UUID,
|
|
p_user_id UUID,
|
|
p_reason TEXT DEFAULT 'cancelled'
|
|
) RETURNS JSON AS $$
|
|
DECLARE
|
|
v_trade RECORD;
|
|
v_offer RECORD;
|
|
BEGIN
|
|
-- Lock trade row
|
|
SELECT * INTO v_trade
|
|
FROM p2p_fiat_trades
|
|
WHERE id = p_trade_id
|
|
FOR UPDATE;
|
|
|
|
IF v_trade IS NULL THEN
|
|
RETURN json_build_object('success', false, 'error', 'Trade not found');
|
|
END IF;
|
|
|
|
-- Only buyer can cancel before payment, or system for expiry
|
|
IF v_trade.status = 'pending' AND v_trade.buyer_id != p_user_id THEN
|
|
RETURN json_build_object('success', false, 'error', 'Only buyer can cancel pending trade');
|
|
END IF;
|
|
|
|
IF v_trade.status NOT IN ('pending', 'payment_sent') THEN
|
|
RETURN json_build_object('success', false, 'error', 'Trade cannot be cancelled in current status');
|
|
END IF;
|
|
|
|
-- Get offer details
|
|
SELECT * INTO v_offer FROM p2p_fiat_offers WHERE id = v_trade.offer_id;
|
|
|
|
-- Update trade status
|
|
UPDATE p2p_fiat_trades
|
|
SET
|
|
status = 'cancelled',
|
|
cancelled_at = NOW(),
|
|
cancelled_by = p_user_id,
|
|
updated_at = NOW()
|
|
WHERE id = p_trade_id;
|
|
|
|
-- Return amount to offer
|
|
UPDATE p2p_fiat_offers
|
|
SET
|
|
remaining_amount = remaining_amount + v_trade.crypto_amount,
|
|
status = 'open',
|
|
updated_at = NOW()
|
|
WHERE id = v_trade.offer_id;
|
|
|
|
RETURN json_build_object(
|
|
'success', true,
|
|
'refunded_amount', v_trade.crypto_amount,
|
|
'reason', p_reason
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- 5. RLS policies for platform escrow
|
|
ALTER TABLE p2p_platform_escrow ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Sellers can view their own escrow
|
|
CREATE POLICY "Sellers can view their escrow"
|
|
ON p2p_platform_escrow FOR SELECT
|
|
USING (seller_id = auth.uid());
|
|
|
|
-- Admins can view all escrow
|
|
CREATE POLICY "Admins can view all escrow"
|
|
ON p2p_platform_escrow FOR SELECT
|
|
USING (
|
|
EXISTS (
|
|
SELECT 1 FROM profiles
|
|
WHERE profiles.id = auth.uid()
|
|
AND profiles.role IN ('admin', 'super_admin')
|
|
)
|
|
);
|
|
|
|
-- Only system can insert/update (via service role)
|
|
-- No INSERT/UPDATE policies for regular users
|
|
|
|
-- 6. Add platform_wallet_address to config
|
|
CREATE TABLE IF NOT EXISTS p2p_config (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
description TEXT,
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Insert platform wallet config (will be updated with real address)
|
|
INSERT INTO p2p_config (key, value, description)
|
|
VALUES (
|
|
'platform_escrow_wallet',
|
|
'5DFwqK698vL4gXHEcanaewnAqhxJ2rjhAogpSTHw3iwGDwd3',
|
|
'Platform wallet address for P2P escrow'
|
|
)
|
|
ON CONFLICT (key) DO NOTHING;
|
|
|
|
COMMENT ON TABLE p2p_platform_escrow IS 'Tracks all escrow deposits for P2P trades';
|
|
COMMENT ON TABLE p2p_config IS 'P2P system configuration';
|