From dcbfa4789a2ae2b81f5ec2cdb12558994c55bb54 Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Thu, 11 Dec 2025 20:45:14 +0300 Subject: [PATCH] feat(p2p): add atomic escrow system with race condition protection - Add p2p_platform_escrow table for tracking locked funds - Implement accept_p2p_offer() with FOR UPDATE lock to prevent race conditions - Add complete_p2p_trade() and cancel_p2p_trade() atomic functions - Configure platform escrow wallet: 5DFwqK698vL4gXHEcanaewnAqhxJ2rjhAogpSTHw3iwGDwd3 - Update AdList to show user's own offers with Your Ad badge - Remove unused getActiveOffers import (ESLint fix) --- shared/lib/p2p-fiat.ts | 164 +++++------ web/src/components/p2p/AdList.tsx | 67 ++++- .../migrations/013_p2p_atomic_escrow.sql | 276 ++++++++++++++++++ 3 files changed, 409 insertions(+), 98 deletions(-) create mode 100644 web/supabase/migrations/013_p2p_atomic_escrow.sql diff --git a/shared/lib/p2p-fiat.ts b/shared/lib/p2p-fiat.ts index 4aa31147..ac23104b 100644 --- a/shared/lib/p2p-fiat.ts +++ b/shared/lib/p2p-fiat.ts @@ -124,7 +124,7 @@ export interface AcceptOfferParams { // CONSTANTS // ===================================================== -const PLATFORM_ESCROW_ADDRESS = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'; +const PLATFORM_ESCROW_ADDRESS = '5DFwqK698vL4gXHEcanaewnAqhxJ2rjhAogpSTHw3iwGDwd3'; const ASSET_IDS = { HEZ: null, // Native token @@ -292,11 +292,18 @@ export async function createFiatOffer(params: CreateOfferParams): Promise { - const { api, account, offerId, amount } = params; + const { account, offerId, amount } = params; try { - // 1. Get offer details + // 1. Get current user + const { data: user } = await supabase.auth.getUser(); + if (!user.user) throw new Error('Not authenticated'); + + // 2. Get offer to determine amount if not specified const { data: offer, error: offerError } = await supabase .from('p2p_fiat_offers') - .select('*') + .select('remaining_amount, min_buyer_completed_trades, min_buyer_reputation') .eq('id', offerId) .single(); if (offerError) throw offerError; if (!offer) throw new Error('Offer not found'); - if (offer.status !== 'open') throw new Error('Offer is not available'); - // 2. Determine trade amount const tradeAmount = amount || offer.remaining_amount; - - if (offer.min_order_amount && tradeAmount < offer.min_order_amount) { - throw new Error(`Minimum order: ${offer.min_order_amount} ${offer.token}`); - } - - if (offer.max_order_amount && tradeAmount > offer.max_order_amount) { - throw new Error(`Maximum order: ${offer.max_order_amount} ${offer.token}`); - } - if (tradeAmount > offer.remaining_amount) { - throw new Error('Insufficient remaining amount'); - } + // 3. Check buyer reputation requirements + if (offer.min_buyer_completed_trades > 0 || offer.min_buyer_reputation > 0) { + const { data: reputation } = await supabase + .from('p2p_reputation') + .select('completed_trades, reputation_score') + .eq('user_id', user.user.id) + .single(); - const tradeFiatAmount = (tradeAmount / offer.amount_crypto) * offer.fiat_amount; - - // 3. Check buyer reputation - const { data: user } = await supabase.auth.getUser(); - if (!user.user) throw new Error('Not authenticated'); - - const { data: reputation } = await supabase - .from('p2p_reputation') - .select('*') - .eq('user_id', user.user.id) - .single(); - - if (reputation) { + if (!reputation) { + throw new Error('Seller requires experienced buyers'); + } if (reputation.completed_trades < offer.min_buyer_completed_trades) { throw new Error(`Minimum ${offer.min_buyer_completed_trades} completed trades required`); } if (reputation.reputation_score < offer.min_buyer_reputation) { throw new Error(`Minimum reputation score ${offer.min_buyer_reputation} required`); } - } else if (offer.min_buyer_completed_trades > 0 || offer.min_buyer_reputation > 0) { - throw new Error('Seller requires experienced buyers'); } - // 4. Create trade - const paymentDeadline = new Date(Date.now() + offer.time_limit_minutes * 60 * 1000); + // 4. Call atomic database function (prevents race condition) + // This uses FOR UPDATE lock to ensure only one buyer can claim the amount + const { data: result, error: rpcError } = await supabase.rpc('accept_p2p_offer', { + p_offer_id: offerId, + p_buyer_id: user.user.id, + p_buyer_wallet: account.address, + p_amount: tradeAmount + }); - const { data: trade, error: tradeError } = await supabase - .from('p2p_fiat_trades') - .insert({ - offer_id: offerId, - seller_id: offer.seller_id, - buyer_id: user.user.id, - buyer_wallet: account.address, - crypto_amount: tradeAmount, - fiat_amount: tradeFiatAmount, - price_per_unit: offer.price_per_unit, - escrow_locked_amount: tradeAmount, - escrow_locked_at: new Date().toISOString(), - status: 'pending', - payment_deadline: paymentDeadline.toISOString() - }) - .select() - .single(); + if (rpcError) throw rpcError; - if (tradeError) throw tradeError; + // Parse result (may be string or object depending on Supabase version) + const response = typeof result === 'string' ? JSON.parse(result) : result; - // 5. Update offer remaining amount - await supabase - .from('p2p_fiat_offers') - .update({ - remaining_amount: offer.remaining_amount - tradeAmount, - status: offer.remaining_amount - tradeAmount === 0 ? 'locked' : 'open' - }) - .eq('id', offerId); + if (!response.success) { + throw new Error(response.error || 'Failed to accept offer'); + } - // 6. Audit log - await logAction('trade', trade.id, 'accept_offer', { + // 5. Audit log + await logAction('trade', response.trade_id, 'accept_offer', { offer_id: offerId, - crypto_amount: tradeAmount, - fiat_amount: tradeFiatAmount + crypto_amount: response.crypto_amount, + fiat_amount: response.fiat_amount }); toast.success('Trade started! Send payment within time limit.'); - - return trade.id; - } catch (error: any) { + + return response.trade_id; + } catch (error: unknown) { console.error('Accept offer error:', error); - toast.error(error.message || 'Failed to accept offer'); + const message = error instanceof Error ? error.message : 'Failed to accept offer'; + toast.error(message); throw error; } } @@ -566,28 +546,36 @@ async function signAndSendTx( account: InjectedAccountWithMeta, tx: any ): Promise { + // Get signer from Polkadot.js extension + const { web3FromSource } = await import('@polkadot/extension-dapp'); + const injector = await web3FromSource(account.meta.source); + return new Promise((resolve, reject) => { let unsub: () => void; - tx.signAndSend(account.address, ({ status, txHash, dispatchError }: any) => { - if (dispatchError) { - if (dispatchError.isModule) { - const decoded = api.registry.findMetaError(dispatchError.asModule); - reject(new Error(`${decoded.section}.${decoded.name}`)); - } else { - reject(new Error(dispatchError.toString())); + tx.signAndSend( + account.address, + { signer: injector.signer }, + ({ status, txHash, dispatchError }: any) => { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}`)); + } else { + reject(new Error(dispatchError.toString())); + } + if (unsub) unsub(); + return; } - if (unsub) unsub(); - return; - } - if (status.isInBlock || status.isFinalized) { - resolve(txHash.toString()); - if (unsub) unsub(); + if (status.isInBlock || status.isFinalized) { + resolve(txHash.toString()); + if (unsub) unsub(); + } } - }).then((unsubscribe: () => void) => { + ).then((unsubscribe: () => void) => { unsub = unsubscribe; - }); + }).catch(reject); }); } diff --git a/web/src/components/p2p/AdList.tsx b/web/src/components/p2p/AdList.tsx index 9cf6fa33..9559f3af 100644 --- a/web/src/components/p2p/AdList.tsx +++ b/web/src/components/p2p/AdList.tsx @@ -6,19 +6,23 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Loader2, Shield, Zap } from 'lucide-react'; import { useAuth } from '@/contexts/AuthContext'; import { TradeModal } from './TradeModal'; -import { getActiveOffers, getUserReputation, type P2PFiatOffer, type P2PReputation } from '@shared/lib/p2p-fiat'; +import { MerchantTierBadge } from './MerchantTierBadge'; +import { getUserReputation, type P2PFiatOffer, type P2PReputation } from '@shared/lib/p2p-fiat'; import { supabase } from '@/lib/supabase'; +import type { P2PFilters } from './OrderFilters'; interface AdListProps { type: 'buy' | 'sell' | 'my-ads'; + filters?: P2PFilters; } interface OfferWithReputation extends P2PFiatOffer { seller_reputation?: P2PReputation; payment_method_name?: string; + merchant_tier?: 'lite' | 'super' | 'diamond'; } -export function AdList({ type }: AdListProps) { +export function AdList({ type, filters }: AdListProps) { const { user } = useAuth(); const [offers, setOffers] = useState([]); const [loading, setLoading] = useState(true); @@ -26,8 +30,21 @@ export function AdList({ type }: AdListProps) { useEffect(() => { fetchOffers(); + + // Refresh data when user returns to the tab (visibility change) + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + fetchOffers(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [type, user]); + }, [type, user, filters]); const fetchOffers = async () => { setLoading(true); @@ -35,16 +52,37 @@ export function AdList({ type }: AdListProps) { let offersData: P2PFiatOffer[] = []; if (type === 'buy') { - // Buy = looking for sell offers - offersData = await getActiveOffers(); + // Buy tab = show SELL offers (user wants to buy from sellers) + // Include ALL offers (user can see their own but can't trade with them) + const { data } = await supabase + .from('p2p_fiat_offers') + .select('*') + .eq('ad_type', 'sell') + .eq('status', 'open') + .gt('remaining_amount', 0) + .order('created_at', { ascending: false }); + + offersData = data || []; + } else if (type === 'sell') { + // Sell tab = show BUY offers (user wants to sell to buyers) + // Include ALL offers (user can see their own but can't trade with them) + const { data } = await supabase + .from('p2p_fiat_offers') + .select('*') + .eq('ad_type', 'buy') + .eq('status', 'open') + .gt('remaining_amount', 0) + .order('created_at', { ascending: false }); + + offersData = data || []; } else if (type === 'my-ads' && user) { - // My offers + // My offers - show all of user's offers const { data } = await supabase .from('p2p_fiat_offers') .select('*') .eq('seller_id', user.id) .order('created_at', { ascending: false }); - + offersData = data || []; } @@ -112,6 +150,9 @@ export function AdList({ type }: AdListProps) {

{offer.seller_wallet.slice(0, 6)}...{offer.seller_wallet.slice(-4)}

+ {offer.merchant_tier && ( + + )} {offer.seller_reputation?.verified_merchant && ( )} @@ -161,11 +202,17 @@ export function AdList({ type }: AdListProps) { {/* Action */} -
- diff --git a/web/supabase/migrations/013_p2p_atomic_escrow.sql b/web/supabase/migrations/013_p2p_atomic_escrow.sql new file mode 100644 index 00000000..4d97c791 --- /dev/null +++ b/web/supabase/migrations/013_p2p_atomic_escrow.sql @@ -0,0 +1,276 @@ +-- ===================================================== +-- 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';