From 508f0763f47b9bbdbe4df7378a67505e70238940 Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Tue, 24 Feb 2026 06:15:22 +0300 Subject: [PATCH] fix: payment proof lifecycle, repeating toast, and escrow migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace IPFS/Pinata upload with Supabase Storage for payment proofs - Add 1-day auto-expiry for proof images (retained if disputed) - Fix repeating "payment deadline expired" toast (fire once, clear interval) - Fix cancel_reason → cancellation_reason column reference - Add payment proof lifecycle migration (proof_expires_at, cleanup functions) - Add atomic escrow migration (accept_p2p_offer, complete/cancel trade) - Add cleanup-proofs edge function for daily expired proof deletion --- shared/lib/p2p-fiat.ts | 20 +- web/src/pages/P2PTrade.tsx | 11 +- .../functions/cleanup-proofs/index.ts | 78 +++++ .../20260224020000_p2p_atomic_escrow.sql | 276 ++++++++++++++++++ ...20260224060000_payment_proof_lifecycle.sql | 62 ++++ 5 files changed, 439 insertions(+), 8 deletions(-) create mode 100644 web/supabase/functions/cleanup-proofs/index.ts create mode 100644 web/supabase/migrations/20260224020000_p2p_atomic_escrow.sql create mode 100644 web/supabase/migrations/20260224060000_payment_proof_lifecycle.sql diff --git a/shared/lib/p2p-fiat.ts b/shared/lib/p2p-fiat.ts index 246a3b62..b25f665f 100644 --- a/shared/lib/p2p-fiat.ts +++ b/shared/lib/p2p-fiat.ts @@ -572,20 +572,32 @@ export async function markPaymentSent( try { let paymentProofUrl: string | undefined; - // 1. Upload payment proof to IPFS if provided + // 1. Upload payment proof to Supabase Storage (auto-expires in 1 day) if (paymentProofFile) { - const { uploadToIPFS } = await import('./ipfs'); - paymentProofUrl = await uploadToIPFS(paymentProofFile); + const fileName = `payment-proofs/${tradeId}/${Date.now()}-${paymentProofFile.name}`; + const { data: uploadData, error: uploadError } = await supabase.storage + .from('p2p-payment-proofs') + .upload(fileName, paymentProofFile); + + if (uploadError) throw uploadError; + + const { data: urlData } = supabase.storage + .from('p2p-payment-proofs') + .getPublicUrl(uploadData.path); + + paymentProofUrl = urlData.publicUrl; } - // 2. Update trade + // 2. Update trade (proof expires in 1 day unless disputed) const confirmationDeadline = new Date(Date.now() + DEFAULT_CONFIRMATION_DEADLINE_MINUTES * 60 * 1000); + const proofExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 1 day const { error } = await supabase .from('p2p_fiat_trades') .update({ buyer_marked_paid_at: new Date().toISOString(), buyer_payment_proof_url: paymentProofUrl, + proof_expires_at: paymentProofUrl ? proofExpiresAt.toISOString() : null, status: 'payment_sent', confirmation_deadline: confirmationDeadline.toISOString() }) diff --git a/web/src/pages/P2PTrade.tsx b/web/src/pages/P2PTrade.tsx index 5715382d..50dcedf0 100644 --- a/web/src/pages/P2PTrade.tsx +++ b/web/src/pages/P2PTrade.tsx @@ -199,14 +199,17 @@ export default function P2PTrade() { const deadline = new Date(trade.payment_deadline).getTime(); + let expired = false; + const updateTimer = () => { const now = Date.now(); const remaining = Math.max(0, Math.floor((deadline - now) / 1000)); setTimeRemaining(remaining); - if (remaining === 0 && trade.status === 'pending') { - // Auto-cancel logic could go here + if (remaining === 0 && !expired) { + expired = true; toast.warning(t('p2pTrade.paymentDeadlineExpired')); + clearInterval(interval); } }; @@ -714,8 +717,8 @@ export default function P2PTrade() {

{t('p2pTrade.tradeCancelled')}

- {trade.cancel_reason && ( -

{t('p2pTrade.cancelReason', { reason: trade.cancel_reason })}

+ {trade.cancellation_reason && ( +

{t('p2pTrade.cancelReason', { reason: trade.cancellation_reason })}

)}
diff --git a/web/supabase/functions/cleanup-proofs/index.ts b/web/supabase/functions/cleanup-proofs/index.ts new file mode 100644 index 00000000..c10e2c90 --- /dev/null +++ b/web/supabase/functions/cleanup-proofs/index.ts @@ -0,0 +1,78 @@ +/** + * Cleanup expired payment proofs + * + * Schedule: Daily via Supabase Dashboard > Database > Extensions > pg_cron + * Or call manually: supabase functions invoke cleanup-proofs + * + * Flow: + * 1. Call DB function to get expired proof URLs and clear them + * 2. Delete actual files from Supabase Storage + */ + +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }) + } + + try { + const supabase = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! + ) + + // 1. Find expired proofs (not disputed, past expiry) + const { data: expiredTrades, error: fetchError } = await supabase + .from('p2p_fiat_trades') + .select('id, buyer_payment_proof_url') + .not('buyer_payment_proof_url', 'is', null) + .not('proof_expires_at', 'is', null) + .lt('proof_expires_at', new Date().toISOString()) + .not('status', 'eq', 'disputed') + + if (fetchError) throw fetchError + + let deleted = 0 + + for (const trade of expiredTrades || []) { + // 2. Extract storage path from URL + const url = trade.buyer_payment_proof_url + const pathMatch = url?.match(/p2p-payment-proofs\/(.+)$/) + + if (pathMatch) { + // 3. Delete file from storage + await supabase.storage + .from('p2p-payment-proofs') + .remove([pathMatch[1]]) + } + + // 4. Clear URL in DB + await supabase + .from('p2p_fiat_trades') + .update({ + buyer_payment_proof_url: null, + proof_expires_at: null, + updated_at: new Date().toISOString(), + }) + .eq('id', trade.id) + + deleted++ + } + + return new Response( + JSON.stringify({ success: true, deleted }), + { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } catch (error) { + return new Response( + JSON.stringify({ success: false, error: error.message }), + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } +}) diff --git a/web/supabase/migrations/20260224020000_p2p_atomic_escrow.sql b/web/supabase/migrations/20260224020000_p2p_atomic_escrow.sql new file mode 100644 index 00000000..4d97c791 --- /dev/null +++ b/web/supabase/migrations/20260224020000_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'; diff --git a/web/supabase/migrations/20260224060000_payment_proof_lifecycle.sql b/web/supabase/migrations/20260224060000_payment_proof_lifecycle.sql new file mode 100644 index 00000000..92dc7512 --- /dev/null +++ b/web/supabase/migrations/20260224060000_payment_proof_lifecycle.sql @@ -0,0 +1,62 @@ +-- Payment proof lifecycle: auto-delete after 1 day, retain if disputed + +-- 1. Add proof_expires_at column +ALTER TABLE public.p2p_fiat_trades + ADD COLUMN IF NOT EXISTS proof_expires_at TIMESTAMPTZ; + +-- 2. Function to clean up expired payment proofs +-- Called by pg_cron or Edge Function daily +CREATE OR REPLACE FUNCTION cleanup_expired_payment_proofs() +RETURNS JSON AS $$ +DECLARE + v_count INT := 0; + v_trade RECORD; +BEGIN + FOR v_trade IN + SELECT id, buyer_payment_proof_url + FROM p2p_fiat_trades + WHERE buyer_payment_proof_url IS NOT NULL + AND proof_expires_at IS NOT NULL + AND proof_expires_at < NOW() + AND status NOT IN ('disputed') + LOOP + UPDATE p2p_fiat_trades + SET buyer_payment_proof_url = NULL, + proof_expires_at = NULL, + updated_at = NOW() + WHERE id = v_trade.id; + + v_count := v_count + 1; + END LOOP; + + RETURN json_build_object('success', true, 'cleaned', v_count); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- 3. Function to retain proof when dispute is opened (set expires_at = NULL) +CREATE OR REPLACE FUNCTION retain_payment_proof(p_trade_id UUID) +RETURNS JSON AS $$ +BEGIN + UPDATE p2p_fiat_trades + SET proof_expires_at = NULL, + updated_at = NOW() + WHERE id = p_trade_id + AND buyer_payment_proof_url IS NOT NULL; + + RETURN json_build_object('success', true); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- 4. Moderator function to delete proof after dispute resolution +CREATE OR REPLACE FUNCTION moderator_clear_payment_proof(p_trade_id UUID) +RETURNS JSON AS $$ +BEGIN + UPDATE p2p_fiat_trades + SET buyer_payment_proof_url = NULL, + proof_expires_at = NULL, + updated_at = NOW() + WHERE id = p_trade_id; + + RETURN json_build_object('success', true); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER;