From bb772668baf733dba39caff341bb55dfdfce7d29 Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Mon, 23 Feb 2026 19:54:57 +0300 Subject: [PATCH] feat: replace supabase auth with citizen/visa identity system for P2P Replace all supabase.auth.getUser() calls with P2PIdentityContext that resolves identity from on-chain citizen NFT or off-chain visa system. - Add identityToUUID() in shared/lib/identity.ts (UUID v5 from citizen/visa number) - Add P2PIdentityContext with citizen NFT detection and visa fallback - Add p2p_visa migration for off-chain visa issuance - Refactor p2p-fiat.ts: all functions now accept userId parameter - Fix all P2P components to use useP2PIdentity() instead of useAuth() - Update verify-deposit edge function: walletToUUID -> identityToUUID - Add P2PLayout with identity gate (wallet/citizen/visa checks) - Wrap all P2P routes with P2PLayout in App.tsx --- shared/lib/identity.ts | 37 +++++ shared/lib/p2p-fiat.ts | 73 +++++---- web/src/App.tsx | 11 +- web/src/components/p2p/AdList.tsx | 16 +- web/src/components/p2p/BlockTrade.tsx | 14 +- web/src/components/p2p/CreateAd.tsx | 59 +++----- web/src/components/p2p/DepositModal.tsx | 3 + web/src/components/p2p/DisputeModal.tsx | 9 +- web/src/components/p2p/ExpressMode.tsx | 16 +- .../components/p2p/InternalBalanceCard.tsx | 7 +- .../components/p2p/MerchantApplication.tsx | 16 +- web/src/components/p2p/NotificationBell.tsx | 24 +-- web/src/components/p2p/P2PDashboard.tsx | 16 +- web/src/components/p2p/P2PLayout.tsx | 121 +++++++++++++++ web/src/components/p2p/RatingModal.tsx | 10 +- web/src/components/p2p/TradeChat.tsx | 28 ++-- web/src/components/p2p/TradeModal.tsx | 14 +- web/src/components/p2p/WithdrawModal.tsx | 10 +- web/src/contexts/P2PIdentityContext.tsx | 141 ++++++++++++++++++ web/src/pages/P2PDispute.tsx | 21 ++- web/src/pages/P2PMerchantDashboard.tsx | 26 ++-- web/src/pages/P2POrders.tsx | 16 +- web/src/pages/P2PTrade.tsx | 41 ++--- .../functions/verify-deposit/index.ts | 17 ++- .../20260223150000_p2p_visa_system.sql | 85 +++++++++++ 25 files changed, 594 insertions(+), 237 deletions(-) create mode 100644 web/src/components/p2p/P2PLayout.tsx create mode 100644 web/src/contexts/P2PIdentityContext.tsx create mode 100644 web/supabase/migrations/20260223150000_p2p_visa_system.sql diff --git a/shared/lib/identity.ts b/shared/lib/identity.ts index ac1a3e61..6eded976 100644 --- a/shared/lib/identity.ts +++ b/shared/lib/identity.ts @@ -1,4 +1,41 @@ // Identity verification types and utilities + +// UUID v5 namespace (RFC 4122 DNS namespace) +const UUID_V5_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; + +/** + * Convert a Citizen Number or Visa Number to a deterministic UUID v5. + * Uses SHA-1 hashing per RFC 4122. Works in both browser and Deno. + * + * @param identityId - Citizen number (e.g. "#42-0-832967") or Visa number (e.g. "V-123456") + * @returns Deterministic UUID v5 string + */ +export async function identityToUUID(identityId: string): Promise { + const namespaceHex = UUID_V5_NAMESPACE.replace(/-/g, ''); + const namespaceBytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) { + namespaceBytes[i] = parseInt(namespaceHex.substr(i * 2, 2), 16); + } + + const nameBytes = new TextEncoder().encode(identityId); + const combined = new Uint8Array(namespaceBytes.length + nameBytes.length); + combined.set(namespaceBytes); + combined.set(nameBytes, namespaceBytes.length); + + const hashBuffer = await crypto.subtle.digest('SHA-1', combined); + const h = new Uint8Array(hashBuffer); + + // Set version 5 and RFC 4122 variant + h[6] = (h[6] & 0x0f) | 0x50; + h[8] = (h[8] & 0x3f) | 0x80; + + const hex = Array.from(h.slice(0, 16)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + export interface IdentityProfile { address: string; verificationLevel: 'none' | 'basic' | 'advanced' | 'verified'; diff --git a/shared/lib/p2p-fiat.ts b/shared/lib/p2p-fiat.ts index 7ed4adba..827a06f0 100644 --- a/shared/lib/p2p-fiat.ts +++ b/shared/lib/p2p-fiat.ts @@ -118,6 +118,8 @@ export interface P2PReputation { } export interface CreateOfferParams { + userId: string; + sellerWallet: string; token: CryptoToken; amountCrypto: number; fiatCurrency: FiatCurrency; @@ -127,14 +129,14 @@ export interface CreateOfferParams { timeLimitMinutes?: number; minOrderAmount?: number; maxOrderAmount?: number; - // NOTE: api and account no longer needed - uses internal ledger + adType?: 'buy' | 'sell'; } export interface AcceptOfferParams { offerId: string; + buyerUserId: string; buyerWallet: string; amount?: number; // If partial order - // NOTE: api and account no longer needed - uses internal ledger } // ===================================================== @@ -375,6 +377,8 @@ async function decryptPaymentDetails(encrypted: string): Promise { const { + userId, + sellerWallet, token, amountCrypto, fiatCurrency, @@ -383,14 +387,12 @@ export async function createFiatOffer(params: CreateOfferParams): Promise { - const { offerId, amount } = params; + const { offerId, buyerUserId, buyerWallet, amount } = params; try { - // 1. Get current user - const { data: user } = await supabase.auth.getUser(); - if (!user.user) throw new Error('Not authenticated'); + if (!buyerUserId) throw new Error('Identity required for P2P trading'); // 2. Get offer to determine amount if not specified const { data: offer, error: offerError } = await supabase @@ -506,7 +507,7 @@ export async function acceptFiatOffer(params: AcceptOfferParams): Promise { +export async function confirmPaymentReceived(tradeId: string, sellerId: string): Promise { try { - // 1. Get current user (seller) - const { data: userData } = await supabase.auth.getUser(); - const sellerId = userData.user?.id; - if (!sellerId) throw new Error('Not authenticated'); + if (!sellerId) throw new Error('Identity required for P2P trading'); // 2. Get trade details const { data: trade, error: tradeError } = await supabase @@ -697,7 +695,7 @@ export async function confirmPaymentReceived(tradeId: string): Promise { released_amount: trade.crypto_amount, token: offer.token, escrow_type: 'internal_ledger' - }); + }, sellerId); toast.success('Payment confirmed! Crypto released to buyer\'s balance.'); } catch (error: unknown) { @@ -766,12 +764,11 @@ async function logAction( entityType: string, entityId: string, action: string, - details: Record + details: Record, + userId?: string ): Promise { - const { data: user } = await supabase.auth.getUser(); - await supabase.from('p2p_audit_log').insert({ - user_id: user.user?.id, + user_id: userId || null, action, entity_type: entityType, entity_id: entityId, @@ -917,7 +914,7 @@ export async function cancelTrade( await logAction('trade', tradeId, 'cancel_trade', { cancelled_by: cancelledBy, reason, - }); + }, cancelledBy); toast.success('Trade cancelled successfully'); } catch (error: unknown) { @@ -985,11 +982,9 @@ export async function updateUserReputation( /** * Get user's internal balances for P2P trading */ -export async function getInternalBalances(): Promise { +export async function getInternalBalances(userId: string): Promise { try { - const { data: userData } = await supabase.auth.getUser(); - const userId = userData.user?.id; - if (!userId) throw new Error('Not authenticated'); + if (!userId) throw new Error('Identity required for P2P trading'); const { data, error } = await supabase.rpc('get_user_internal_balance', { p_user_id: userId @@ -1009,8 +1004,8 @@ export async function getInternalBalances(): Promise { /** * Get user's internal balance for a specific token */ -export async function getInternalBalance(token: CryptoToken): Promise { - const balances = await getInternalBalances(); +export async function getInternalBalance(userId: string, token: CryptoToken): Promise { + const balances = await getInternalBalances(userId); return balances.find(b => b.token === token) || null; } @@ -1019,14 +1014,13 @@ export async function getInternalBalance(token: CryptoToken): Promise { try { - const { data: userData } = await supabase.auth.getUser(); - const userId = userData.user?.id; - if (!userId) throw new Error('Not authenticated'); + if (!userId) throw new Error('Identity required for P2P trading'); // Validate amount if (amount <= 0) throw new Error('Amount must be greater than 0'); @@ -1069,11 +1063,14 @@ export async function requestWithdraw( /** * Get user's deposit/withdraw request history */ -export async function getDepositWithdrawHistory(): Promise { +export async function getDepositWithdrawHistory(userId: string): Promise { try { + if (!userId) throw new Error('Identity required for P2P trading'); + const { data, error } = await supabase .from('p2p_deposit_withdraw_requests') .select('*') + .eq('user_id', userId) .order('created_at', { ascending: false }) .limit(50); diff --git a/web/src/App.tsx b/web/src/App.tsx index 7b37c6cd..ddbfd883 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,7 @@ import { AuthProvider } from '@/contexts/AuthContext'; import { DashboardProvider } from '@/contexts/DashboardContext'; import { ReferralProvider } from '@/contexts/ReferralContext'; import { ProtectedRoute } from '@/components/ProtectedRoute'; +import { P2PLayout } from '@/components/p2p/P2PLayout'; import { Toaster } from '@/components/ui/toaster'; import { ErrorBoundary } from '@/components/ErrorBoundary'; import { initSentry } from '@/lib/sentry'; @@ -180,27 +181,27 @@ function App() { } /> - + } /> - + } /> - + } /> - + } /> - + } /> ([]); const [loading, setLoading] = useState(true); const [selectedOffer, setSelectedOffer] = useState(null); @@ -46,7 +46,7 @@ export function AdList({ type, filters }: AdListProps) { document.removeEventListener('visibilitychange', handleVisibilityChange); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [type, user, filters]); + }, [type, userId, filters]); const fetchOffers = async () => { setLoading(true); @@ -62,9 +62,9 @@ export function AdList({ type, filters }: AdListProps) { } else if (type === 'sell') { // Sell tab = show BUY offers (user wants to sell to buyers) query = query.eq('ad_type', 'buy').eq('status', 'open').gt('remaining_amount', 0); - } else if (type === 'my-ads' && user) { + } else if (type === 'my-ads' && userId) { // My offers - show all of user's offers - query = query.eq('seller_id', user.id); + query = query.eq('seller_id', userId); } // Apply filters if provided @@ -263,16 +263,16 @@ export function AdList({ type, filters }: AdListProps) { {/* Action */}
- {offer.seller_id === user?.id && type !== 'my-ads' && ( + {offer.seller_id === userId && type !== 'my-ads' && ( {t('p2pAd.yourAd')} )} diff --git a/web/src/components/p2p/BlockTrade.tsx b/web/src/components/p2p/BlockTrade.tsx index d18df2a6..7e2f64e7 100644 --- a/web/src/components/p2p/BlockTrade.tsx +++ b/web/src/components/p2p/BlockTrade.tsx @@ -24,7 +24,7 @@ import { Building2, AlertTriangle } from 'lucide-react'; import { supabase } from '@/lib/supabase'; -import { useAuth } from '@/contexts/AuthContext'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import type { CryptoToken, FiatCurrency } from '@pezkuwi/lib/p2p-fiat'; @@ -80,19 +80,19 @@ export function BlockTrade() { const [requests, setRequests] = useState([]); const { t } = useTranslation(); - const { user } = useAuth(); + const { userId } = useP2PIdentity(); const fiatSymbol = SUPPORTED_FIATS.find(f => f.code === fiat)?.symbol || ''; const minAmount = MINIMUM_BLOCK_AMOUNTS[token]; // Fetch user's block trade requests React.useEffect(() => { - if (!user) return; + if (!userId) return; const fetchRequests = async () => { const { data, error } = await supabase .from('p2p_block_trade_requests') .select('*') - .eq('user_id', user.id) + .eq('user_id', userId) .order('created_at', { ascending: false }); if (!error && data) { @@ -101,10 +101,10 @@ export function BlockTrade() { }; fetchRequests(); - }, [user]); + }, [userId]); const handleSubmitRequest = async () => { - if (!user) { + if (!userId) { toast.error(t('p2pBlock.loginRequired')); return; } @@ -120,7 +120,7 @@ export function BlockTrade() { const { data, error } = await supabase .from('p2p_block_trade_requests') .insert({ - user_id: user.id, + user_id: userId, type, token, fiat_currency: fiat, diff --git a/web/src/components/p2p/CreateAd.tsx b/web/src/components/p2p/CreateAd.tsx index 12ddf67e..738f15e5 100644 --- a/web/src/components/p2p/CreateAd.tsx +++ b/web/src/components/p2p/CreateAd.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { useAuth } from '@/contexts/AuthContext'; -import { useWallet } from '@/contexts/WalletContext'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -9,8 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { toast } from 'sonner'; import { Loader2 } from 'lucide-react'; -import { supabase } from '@/lib/supabase'; import { + createFiatOffer, getPaymentMethods, validatePaymentDetails, type PaymentMethod, @@ -24,8 +24,8 @@ interface CreateAdProps { export function CreateAd({ onAdCreated }: CreateAdProps) { const { t } = useTranslation(); - const { user } = useAuth(); - const { account } = useWallet(); + const { selectedAccount } = usePezkuwi(); + const { userId } = useP2PIdentity(); const [paymentMethods, setPaymentMethods] = useState([]); const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(null); @@ -77,11 +77,8 @@ export function CreateAd({ onAdCreated }: CreateAdProps) { }; const handleCreateAd = async () => { - console.log('🔥 handleCreateAd called', { account, user: user?.id }); - - if (!account || !user) { + if (!selectedAccount || !userId) { toast.error(t('p2p.connectWalletAndLogin')); - console.log('❌ No account or user', { account, user }); return; } @@ -129,37 +126,21 @@ export function CreateAd({ onAdCreated }: CreateAdProps) { setLoading(true); try { - // Insert offer into Supabase - // Note: payment_details_encrypted is stored as JSON string (encryption handled server-side in prod) - const { data, error } = await supabase - .from('p2p_fiat_offers') - .insert({ - seller_id: user.id, - seller_wallet: account, - ad_type: adType, - token, - amount_crypto: cryptoAmt, - remaining_amount: cryptoAmt, - fiat_currency: fiatCurrency, - fiat_amount: fiatAmt, - payment_method_id: selectedPaymentMethod.id, - payment_details_encrypted: JSON.stringify(paymentDetails), - time_limit_minutes: timeLimit, - min_order_amount: minOrderAmount ? parseFloat(minOrderAmount) : null, - max_order_amount: maxOrderAmount ? parseFloat(maxOrderAmount) : null, - status: 'open' - }) - .select() - .single(); + await createFiatOffer({ + userId, + sellerWallet: selectedAccount.address, + token, + amountCrypto: cryptoAmt, + fiatCurrency, + fiatAmount: fiatAmt, + paymentMethodId: selectedPaymentMethod.id, + paymentDetails, + timeLimitMinutes: timeLimit, + minOrderAmount: minOrderAmount ? parseFloat(minOrderAmount) : undefined, + maxOrderAmount: maxOrderAmount ? parseFloat(maxOrderAmount) : undefined, + adType, + }); - if (error) { - console.error('❌ Supabase error:', error); - toast.error(error.message || t('p2pCreate.failedToCreate')); - return; - } - - console.log('✅ Offer created successfully:', data); - toast.success(t('p2pCreate.adCreated')); onAdCreated(); } catch (error) { if (import.meta.env.DEV) console.error('Create ad error:', error); diff --git a/web/src/components/p2p/DepositModal.tsx b/web/src/components/p2p/DepositModal.tsx index 114cbd79..72c41272 100644 --- a/web/src/components/p2p/DepositModal.tsx +++ b/web/src/components/p2p/DepositModal.tsx @@ -31,6 +31,7 @@ import { } from 'lucide-react'; import { usePezkuwi } from '@/contexts/PezkuwiContext'; import { useWallet } from '@/contexts/WalletContext'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { toast } from 'sonner'; import { @@ -50,6 +51,7 @@ export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps) const { t } = useTranslation(); const { api, selectedAccount } = usePezkuwi(); const { balances, signTransaction } = useWallet(); + const { identityId } = useP2PIdentity(); const [step, setStep] = useState('select'); const [token, setToken] = useState('HEZ'); @@ -192,6 +194,7 @@ export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps) token, expectedAmount: depositAmount, walletAddress: selectedAccount?.address, + identityId, ...(blockNumber ? { blockNumber } : {}) }) }); diff --git a/web/src/components/p2p/DisputeModal.tsx b/web/src/components/p2p/DisputeModal.tsx index 8dfb5e4d..c5284029 100644 --- a/web/src/components/p2p/DisputeModal.tsx +++ b/web/src/components/p2p/DisputeModal.tsx @@ -22,6 +22,7 @@ import { import { AlertTriangle, Upload, X, FileText } from 'lucide-react'; import { supabase } from '@/lib/supabase'; import { toast } from 'sonner'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; interface DisputeModalProps { isOpen: boolean; @@ -62,6 +63,7 @@ export function DisputeModal({ isBuyer, }: DisputeModalProps) { const { t } = useTranslation(); + const { userId } = useP2PIdentity(); const fileInputRef = useRef(null); const [reason, setReason] = useState(''); @@ -172,15 +174,14 @@ export function DisputeModal({ setIsSubmitting(true); try { - const { data: { user } } = await supabase.auth.getUser(); - if (!user) throw new Error('Not authenticated'); + if (!userId) throw new Error('Not authenticated'); // Create dispute const { data: dispute, error: disputeError } = await supabase .from('p2p_disputes') .insert({ trade_id: tradeId, - opened_by: user.id, + opened_by: userId, reason, description, status: 'open', @@ -197,7 +198,7 @@ export function DisputeModal({ // Insert evidence records const evidenceRecords = evidenceUrls.map((url, index) => ({ dispute_id: dispute.id, - uploaded_by: user.id, + uploaded_by: userId, evidence_type: evidenceFiles[index].type === 'image' ? 'screenshot' : 'document', file_url: url, description: `Evidence ${index + 1}`, diff --git a/web/src/components/p2p/ExpressMode.tsx b/web/src/components/p2p/ExpressMode.tsx index 2451f56b..fb45373e 100644 --- a/web/src/components/p2p/ExpressMode.tsx +++ b/web/src/components/p2p/ExpressMode.tsx @@ -16,7 +16,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Zap, ArrowRight, Shield, Clock, Star, AlertCircle, CheckCircle2 } from 'lucide-react'; import { supabase } from '@/lib/supabase'; -import { useAuth } from '@/contexts/AuthContext'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -69,7 +70,8 @@ export function ExpressMode({ onTradeStarted }: ExpressModeProps) { const [isProcessing, setIsProcessing] = useState(false); const { t } = useTranslation(); - const { user } = useAuth(); + const { selectedAccount } = usePezkuwi(); + const { userId } = useP2PIdentity(); const navigate = useNavigate(); // Calculate conversion @@ -140,8 +142,8 @@ export function ExpressMode({ onTradeStarted }: ExpressModeProps) { // Handle express trade const handleExpressTrade = async () => { - if (!user) { - toast.error(t('p2pExpress.loginRequired')); + if (!userId || !selectedAccount) { + toast.error(t('p2p.connectWalletAndLogin')); return; } @@ -160,8 +162,8 @@ export function ExpressMode({ onTradeStarted }: ExpressModeProps) { // Accept the best offer const { data: result, error } = await supabase.rpc('accept_p2p_offer', { p_offer_id: bestOffer.id, - p_buyer_id: user.id, - p_buyer_wallet: '', // Will be set from user profile + p_buyer_id: userId, + p_buyer_wallet: selectedAccount.address, p_amount: cryptoAmount }); @@ -342,7 +344,7 @@ export function ExpressMode({ onTradeStarted }: ExpressModeProps) { className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-semibold" size="lg" onClick={handleExpressTrade} - disabled={!bestOffer || isLoading || isProcessing || !user} + disabled={!bestOffer || isLoading || isProcessing || !userId || !selectedAccount} > {isProcessing ? ( <>{t('p2pExpress.processing')} diff --git a/web/src/components/p2p/InternalBalanceCard.tsx b/web/src/components/p2p/InternalBalanceCard.tsx index 637790a2..38dae9dc 100644 --- a/web/src/components/p2p/InternalBalanceCard.tsx +++ b/web/src/components/p2p/InternalBalanceCard.tsx @@ -13,6 +13,7 @@ import { Unlock } from 'lucide-react'; import { getInternalBalances, type InternalBalance } from '@shared/lib/p2p-fiat'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; interface InternalBalanceCardProps { onDeposit?: () => void; @@ -21,13 +22,15 @@ interface InternalBalanceCardProps { export function InternalBalanceCard({ onDeposit, onWithdraw }: InternalBalanceCardProps) { const { t } = useTranslation(); + const { userId } = useP2PIdentity(); const [balances, setBalances] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const fetchBalances = async () => { + if (!userId) return; try { - const data = await getInternalBalances(); + const data = await getInternalBalances(userId); setBalances(data); } catch (error) { console.error('Failed to fetch balances:', error); @@ -39,7 +42,7 @@ export function InternalBalanceCard({ onDeposit, onWithdraw }: InternalBalanceCa useEffect(() => { fetchBalances(); - }, []); + }, [userId]); const handleRefresh = async () => { setIsRefreshing(true); diff --git a/web/src/components/p2p/MerchantApplication.tsx b/web/src/components/p2p/MerchantApplication.tsx index 9546fb4d..daa07a6d 100644 --- a/web/src/components/p2p/MerchantApplication.tsx +++ b/web/src/components/p2p/MerchantApplication.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { supabase } from '@/lib/supabase'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; @@ -107,6 +108,7 @@ const TIER_COLORS = { export function MerchantApplication() { const { t } = useTranslation(); + const { userId } = useP2PIdentity(); const [loading, setLoading] = useState(true); const [requirements, setRequirements] = useState(DEFAULT_REQUIREMENTS); const [userStats, setUserStats] = useState({ completed_trades: 0, completion_rate: 0, volume_30d: 0 }); @@ -123,8 +125,7 @@ export function MerchantApplication() { const fetchData = async () => { setLoading(true); try { - const { data: { user } } = await supabase.auth.getUser(); - if (!user) return; + if (!userId) return; // Fetch tier requirements const { data: reqData } = await supabase @@ -140,21 +141,21 @@ export function MerchantApplication() { const { data: repData } = await supabase .from('p2p_reputation') .select('completed_trades') - .eq('user_id', user.id) + .eq('user_id', userId) .single(); // Fetch merchant stats const { data: statsData } = await supabase .from('p2p_merchant_stats') .select('completion_rate_30d, total_volume_30d') - .eq('user_id', user.id) + .eq('user_id', userId) .single(); // Fetch current tier const { data: tierData } = await supabase .from('p2p_merchant_tiers') .select('tier, application_status, applied_for_tier') - .eq('user_id', user.id) + .eq('user_id', userId) .single(); setUserStats({ @@ -207,11 +208,10 @@ export function MerchantApplication() { setApplying(true); try { - const { data: { user } } = await supabase.auth.getUser(); - if (!user) throw new Error('Not authenticated'); + if (!userId) throw new Error('Not authenticated'); const { data, error } = await supabase.rpc('apply_for_tier_upgrade', { - p_user_id: user.id, + p_user_id: userId, p_target_tier: selectedTier }); diff --git a/web/src/components/p2p/NotificationBell.tsx b/web/src/components/p2p/NotificationBell.tsx index d1183b54..e56dda5e 100644 --- a/web/src/components/p2p/NotificationBell.tsx +++ b/web/src/components/p2p/NotificationBell.tsx @@ -22,7 +22,7 @@ import { Loader2, CheckCheck, } from 'lucide-react'; -import { useAuth } from '@/contexts/AuthContext'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { supabase } from '@/lib/supabase'; interface Notification { @@ -40,7 +40,7 @@ interface Notification { export function NotificationBell() { const { t } = useTranslation(); const navigate = useNavigate(); - const { user } = useAuth(); + const { userId } = useP2PIdentity(); const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [loading, setLoading] = useState(true); @@ -48,13 +48,13 @@ export function NotificationBell() { // Fetch notifications const fetchNotifications = useCallback(async () => { - if (!user) return; + if (!userId) return; try { const { data, error } = await supabase .from('p2p_notifications') .select('*') - .eq('user_id', user.id) + .eq('user_id', userId) .order('created_at', { ascending: false }) .limit(20); @@ -67,7 +67,7 @@ export function NotificationBell() { } finally { setLoading(false); } - }, [user]); + }, [userId]); // Initial fetch useEffect(() => { @@ -76,17 +76,17 @@ export function NotificationBell() { // Real-time subscription useEffect(() => { - if (!user) return; + if (!userId) return; const channel = supabase - .channel(`notifications-${user.id}`) + .channel(`notifications-${userId}`) .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'p2p_notifications', - filter: `user_id=eq.${user.id}`, + filter: `user_id=eq.${userId}`, }, (payload) => { const newNotif = payload.new as Notification; @@ -99,7 +99,7 @@ export function NotificationBell() { return () => { supabase.removeChannel(channel); }; - }, [user]); + }, [userId]); // Mark as read const markAsRead = async (notificationId: string) => { @@ -120,13 +120,13 @@ export function NotificationBell() { // Mark all as read const markAllAsRead = async () => { - if (!user) return; + if (!userId) return; try { await supabase .from('p2p_notifications') .update({ is_read: true }) - .eq('user_id', user.id) + .eq('user_id', userId) .eq('is_read', false); setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))); @@ -184,7 +184,7 @@ export function NotificationBell() { return t('p2p.daysAgo', { count: days }); }; - if (!user) return null; + if (!userId) return null; return ( diff --git a/web/src/components/p2p/P2PDashboard.tsx b/web/src/components/p2p/P2PDashboard.tsx index 13bff1c8..5a30b68a 100644 --- a/web/src/components/p2p/P2PDashboard.tsx +++ b/web/src/components/p2p/P2PDashboard.tsx @@ -16,7 +16,7 @@ import { WithdrawModal } from './WithdrawModal'; import { ExpressMode } from './ExpressMode'; import { BlockTrade } from './BlockTrade'; import { DEFAULT_FILTERS, type P2PFilters } from './types'; -import { useAuth } from '@/contexts/AuthContext'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { supabase } from '@/lib/supabase'; interface UserStats { @@ -34,7 +34,7 @@ export function P2PDashboard() { const [showWithdrawModal, setShowWithdrawModal] = useState(false); const [balanceRefreshKey, setBalanceRefreshKey] = useState(0); const navigate = useNavigate(); - const { user } = useAuth(); + const { userId } = useP2PIdentity(); const handleBalanceUpdated = () => { setBalanceRefreshKey(prev => prev + 1); @@ -43,28 +43,28 @@ export function P2PDashboard() { // Fetch user stats useEffect(() => { const fetchStats = async () => { - if (!user) return; + if (!userId) return; try { // Count active trades const { count: activeCount } = await supabase .from('p2p_fiat_trades') .select('*', { count: 'exact', head: true }) - .or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`) + .or(`seller_id.eq.${userId},buyer_id.eq.${userId}`) .in('status', ['pending', 'payment_sent']); // Count completed trades const { count: completedCount } = await supabase .from('p2p_fiat_trades') .select('*', { count: 'exact', head: true }) - .or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`) + .or(`seller_id.eq.${userId},buyer_id.eq.${userId}`) .eq('status', 'completed'); // Calculate total volume const { data: trades } = await supabase .from('p2p_fiat_trades') .select('fiat_amount') - .or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`) + .or(`seller_id.eq.${userId},buyer_id.eq.${userId}`) .eq('status', 'completed'); const totalVolume = trades?.reduce((sum, t) => sum + (t.fiat_amount || 0), 0) || 0; @@ -80,7 +80,7 @@ export function P2PDashboard() { }; fetchStats(); - }, [user]); + }, [userId]); return (
@@ -120,7 +120,7 @@ export function P2PDashboard() {
{/* Stats Cards and Balance Card */} - {user && ( + {userId && (
{/* Internal Balance Card - Takes more space */}
diff --git a/web/src/components/p2p/P2PLayout.tsx b/web/src/components/p2p/P2PLayout.tsx new file mode 100644 index 00000000..3a6a963a --- /dev/null +++ b/web/src/components/p2p/P2PLayout.tsx @@ -0,0 +1,121 @@ +import { ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { P2PIdentityProvider, useP2PIdentity } from '@/contexts/P2PIdentityContext'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, Shield, UserCheck, Wallet, Home } from 'lucide-react'; + +function IdentityGate({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { selectedAccount } = usePezkuwi(); + const { hasIdentity, loading, applyForVisa } = useP2PIdentity(); + + // Loading state + if (loading) { + return ( +
+ +

{t('p2pIdentity.resolving')}

+
+ ); + } + + // No wallet connected + if (!selectedAccount) { + return ( +
+ + +
+ +
+ {t('p2pIdentity.connectWalletTitle')} + {t('p2pIdentity.connectWalletDesc')} +
+ + + +
+
+ ); + } + + // Has identity - show content with identity badge + if (hasIdentity) { + return <>{children}; + } + + // No identity - show visa application + return ( +
+ + +
+ +
+ {t('p2pIdentity.identityRequired')} + {t('p2pIdentity.identityRequiredDesc')} +
+ +
+
+
+ + {t('p2pIdentity.citizenOption')} + {t('p2pIdentity.fullAccess')} +
+

{t('p2pIdentity.citizenDesc')}

+ +
+ +
+
+ + {t('p2pIdentity.visaOption')} + {t('p2pIdentity.limitedAccess')} +
+

{t('p2pIdentity.visaDesc')}

+ +
+
+ + +
+
+
+ ); +} + +export function P2PLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/web/src/components/p2p/RatingModal.tsx b/web/src/components/p2p/RatingModal.tsx index ac444727..6a9f511d 100644 --- a/web/src/components/p2p/RatingModal.tsx +++ b/web/src/components/p2p/RatingModal.tsx @@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Star, Loader2, ThumbsUp, ThumbsDown } from 'lucide-react'; -import { useAuth } from '@/contexts/AuthContext'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { supabase } from '@/lib/supabase'; @@ -34,14 +34,14 @@ export function RatingModal({ isBuyer, }: RatingModalProps) { const { t } = useTranslation(); - const { user } = useAuth(); + const { userId } = useP2PIdentity(); const [rating, setRating] = useState(0); const [hoveredRating, setHoveredRating] = useState(0); const [review, setReview] = useState(''); const [loading, setLoading] = useState(false); const handleSubmit = async () => { - if (!user || rating === 0) { + if (!userId || rating === 0) { toast.error(t('p2pRating.selectRatingError')); return; } @@ -54,7 +54,7 @@ export function RatingModal({ .from('p2p_ratings') .select('id') .eq('trade_id', tradeId) - .eq('rater_id', user.id) + .eq('rater_id', userId) .single(); if (existingRating) { @@ -66,7 +66,7 @@ export function RatingModal({ // Insert rating const { error: ratingError } = await supabase.from('p2p_ratings').insert({ trade_id: tradeId, - rater_id: user.id, + rater_id: userId, rated_id: counterpartyId, rating, review: review.trim() || null, diff --git a/web/src/components/p2p/TradeChat.tsx b/web/src/components/p2p/TradeChat.tsx index f7c319f6..0f2d6196 100644 --- a/web/src/components/p2p/TradeChat.tsx +++ b/web/src/components/p2p/TradeChat.tsx @@ -13,7 +13,7 @@ import { Clock, Bot, } from 'lucide-react'; -import { useAuth } from '@/contexts/AuthContext'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { supabase } from '@/lib/supabase'; @@ -43,7 +43,7 @@ export function TradeChat({ isTradeActive, }: TradeChatProps) { const { t } = useTranslation(); - const { user } = useAuth(); + const { userId } = useP2PIdentity(); const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); const [loading, setLoading] = useState(true); @@ -72,9 +72,9 @@ export function TradeChat({ setMessages(data || []); // Mark messages as read - if (user && data && data.length > 0) { + if (userId && data && data.length > 0) { const unreadIds = data - .filter(m => m.sender_id !== user.id && !m.is_read) + .filter(m => m.sender_id !== userId && !m.is_read) .map(m => m.id); if (unreadIds.length > 0) { @@ -89,7 +89,7 @@ export function TradeChat({ } finally { setLoading(false); } - }, [tradeId, user]); + }, [tradeId, userId]); // Initial fetch useEffect(() => { @@ -117,7 +117,7 @@ export function TradeChat({ }); // Mark as read if from counterparty - if (user && newMsg.sender_id !== user.id) { + if (userId && newMsg.sender_id !== userId) { supabase .from('p2p_messages') .update({ is_read: true }) @@ -130,7 +130,7 @@ export function TradeChat({ return () => { supabase.removeChannel(channel); }; - }, [tradeId, user]); + }, [tradeId, userId]); // Scroll to bottom on new messages useEffect(() => { @@ -139,7 +139,7 @@ export function TradeChat({ // Send message const handleSendMessage = async () => { - if (!newMessage.trim() || !user || sending) return; + if (!newMessage.trim() || !userId || sending) return; const messageText = newMessage.trim(); setNewMessage(''); @@ -148,7 +148,7 @@ export function TradeChat({ try { const { error } = await supabase.from('p2p_messages').insert({ trade_id: tradeId, - sender_id: user.id, + sender_id: userId, message: messageText, message_type: 'text', is_read: false, @@ -186,7 +186,7 @@ export function TradeChat({ // Upload image const handleImageUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (!file || !user) return; + if (!file || !userId) return; // Validate file if (!file.type.startsWith('image/')) { @@ -218,7 +218,7 @@ export function TradeChat({ // Insert message with image const { error: msgError } = await supabase.from('p2p_messages').insert({ trade_id: tradeId, - sender_id: user.id, + sender_id: userId, message: t('p2pChat.sentImage'), message_type: 'image', attachment_url: urlData.publicUrl, @@ -258,7 +258,7 @@ export function TradeChat({ // Render message const renderMessage = (message: Message) => { - const isOwn = message.sender_id === user?.id; + const isOwn = message.sender_id === userId; const isSystem = message.message_type === 'system'; if (isSystem) { @@ -332,9 +332,9 @@ export function TradeChat({ {t('p2pChat.title')} - {messages.filter(m => m.sender_id !== user?.id && !m.is_read).length > 0 && ( + {messages.filter(m => m.sender_id !== userId && !m.is_read).length > 0 && ( - {messages.filter(m => m.sender_id !== user?.id && !m.is_read).length} + {messages.filter(m => m.sender_id !== userId && !m.is_read).length} )} diff --git a/web/src/components/p2p/TradeModal.tsx b/web/src/components/p2p/TradeModal.tsx index e01a94e1..3fe2374a 100644 --- a/web/src/components/p2p/TradeModal.tsx +++ b/web/src/components/p2p/TradeModal.tsx @@ -14,8 +14,8 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Loader2, AlertTriangle, Clock } from 'lucide-react'; -import { useAuth } from '@/contexts/AuthContext'; import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { toast } from 'sonner'; import { acceptFiatOffer, type P2PFiatOffer } from '@shared/lib/p2p-fiat'; @@ -27,8 +27,8 @@ interface TradeModalProps { export function TradeModal({ offer, onClose }: TradeModalProps) { const { t } = useTranslation(); const navigate = useNavigate(); - const { user } = useAuth(); - const { api, selectedAccount } = usePezkuwi(); + const { selectedAccount } = usePezkuwi(); + const { userId } = useP2PIdentity(); const [amount, setAmount] = useState(''); const [loading, setLoading] = useState(false); @@ -41,13 +41,13 @@ export function TradeModal({ offer, onClose }: TradeModalProps) { const meetsMaxOrder = !offer.max_order_amount || cryptoAmount <= offer.max_order_amount; const handleInitiateTrade = async () => { - if (!api || !selectedAccount || !user) { + if (!selectedAccount || !userId) { toast.error(t('p2p.connectWalletAndLogin')); return; } // Prevent self-trading - if (offer.seller_id === user.id) { + if (offer.seller_id === userId) { toast.error(t('p2pTrade.cannotTradeOwn')); return; } @@ -71,9 +71,9 @@ export function TradeModal({ offer, onClose }: TradeModalProps) { try { const tradeId = await acceptFiatOffer({ - api, - account: selectedAccount, offerId: offer.id, + buyerUserId: userId, + buyerWallet: selectedAccount.address, amount: cryptoAmount }); diff --git a/web/src/components/p2p/WithdrawModal.tsx b/web/src/components/p2p/WithdrawModal.tsx index ea2f0116..e4c9d68e 100644 --- a/web/src/components/p2p/WithdrawModal.tsx +++ b/web/src/components/p2p/WithdrawModal.tsx @@ -30,6 +30,7 @@ import { Info } from 'lucide-react'; import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { toast } from 'sonner'; import { getInternalBalances, @@ -51,6 +52,7 @@ type WithdrawStep = 'form' | 'confirm' | 'success'; export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps) { const { t } = useTranslation(); const { selectedAccount } = usePezkuwi(); + const { userId } = useP2PIdentity(); const [step, setStep] = useState('form'); const [token, setToken] = useState('HEZ'); @@ -80,9 +82,10 @@ export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps const fetchData = async () => { setLoading(true); try { + if (!userId) return; const [balanceData, historyData] = await Promise.all([ - getInternalBalances(), - getDepositWithdrawHistory() + getInternalBalances(userId), + getDepositWithdrawHistory(userId) ]); setBalances(balanceData); // Filter for pending withdrawal requests @@ -180,7 +183,8 @@ export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps try { const withdrawAmount = parseFloat(amount); - const id = await requestWithdraw(token, withdrawAmount, walletAddress); + if (!userId) throw new Error('Identity required'); + const id = await requestWithdraw(userId, token, withdrawAmount, walletAddress); setRequestId(id); setStep('success'); onSuccess?.(); diff --git a/web/src/contexts/P2PIdentityContext.tsx b/web/src/contexts/P2PIdentityContext.tsx new file mode 100644 index 00000000..d5aa69eb --- /dev/null +++ b/web/src/contexts/P2PIdentityContext.tsx @@ -0,0 +1,141 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { useDashboard } from '@/contexts/DashboardContext'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { identityToUUID } from '@shared/lib/identity'; +import { supabase } from '@/lib/supabase'; + +interface P2PIdentity { + /** UUID v5 derived from identityId, used as user_id in DB */ + userId: string | null; + /** Full citizen number (#42-0-832967) or visa number (V-123456) */ + identityId: string | null; + /** Wallet address (SS58) */ + walletAddress: string | null; + /** Whether user is a citizen with on-chain NFT */ + isCitizen: boolean; + /** Whether user has an off-chain visa */ + isVisa: boolean; + /** Whether user has any P2P identity (citizen or visa) */ + hasIdentity: boolean; + /** Loading state during identity resolution */ + loading: boolean; + /** Apply for a visa (for non-citizens) */ + applyForVisa: () => Promise; +} + +const P2PIdentityContext = createContext(undefined); + +export function P2PIdentityProvider({ children }: { children: ReactNode }) { + const { citizenNumber, nftDetails, loading: dashboardLoading } = useDashboard(); + const { selectedAccount } = usePezkuwi(); + + const [userId, setUserId] = useState(null); + const [identityId, setIdentityId] = useState(null); + const [visaNumber, setVisaNumber] = useState(null); + const [loading, setLoading] = useState(true); + + const walletAddress = selectedAccount?.address || null; + const isCitizen = !!(nftDetails.citizenNFT && citizenNumber !== 'N/A'); + const isVisa = !!visaNumber; + const hasIdentity = isCitizen || isVisa; + + // Resolve identity when wallet/dashboard data changes + useEffect(() => { + if (dashboardLoading) return; + + const resolve = async () => { + setLoading(true); + + try { + if (isCitizen) { + // Citizen: use full citizen number as identity + const fullCitizenNumber = `#${nftDetails.citizenNFT!.collectionId}-${nftDetails.citizenNFT!.itemId}-${citizenNumber}`; + setIdentityId(fullCitizenNumber); + const uuid = await identityToUUID(fullCitizenNumber); + setUserId(uuid); + setVisaNumber(null); + } else if (walletAddress) { + // Non-citizen: check for existing visa + const { data: visa } = await supabase + .from('p2p_visa') + .select('visa_number, status') + .eq('wallet_address', walletAddress) + .eq('status', 'active') + .maybeSingle(); + + if (visa) { + setVisaNumber(visa.visa_number); + setIdentityId(visa.visa_number); + const uuid = await identityToUUID(visa.visa_number); + setUserId(uuid); + } else { + setVisaNumber(null); + setIdentityId(null); + setUserId(null); + } + } else { + setIdentityId(null); + setUserId(null); + setVisaNumber(null); + } + } catch (error) { + console.error('P2P identity resolution error:', error); + setIdentityId(null); + setUserId(null); + } finally { + setLoading(false); + } + }; + + resolve(); + }, [isCitizen, citizenNumber, nftDetails.citizenNFT, walletAddress, dashboardLoading]); + + const applyForVisa = async (): Promise => { + if (!walletAddress) return null; + if (isCitizen) return null; // Citizens don't need visas + + try { + const { data, error } = await supabase.rpc('issue_p2p_visa', { + p_wallet_address: walletAddress, + }); + + if (error) throw error; + + if (data?.success) { + const vn = data.visa_number as string; + setVisaNumber(vn); + setIdentityId(vn); + const uuid = await identityToUUID(vn); + setUserId(uuid); + return vn; + } + return null; + } catch (error) { + console.error('Failed to apply for visa:', error); + return null; + } + }; + + return ( + + {children} + + ); +} + +export function useP2PIdentity() { + const context = useContext(P2PIdentityContext); + if (context === undefined) { + throw new Error('useP2PIdentity must be used within a P2PIdentityProvider'); + } + return context; +} diff --git a/web/src/pages/P2PDispute.tsx b/web/src/pages/P2PDispute.tsx index 3cf20eb3..9babf84a 100644 --- a/web/src/pages/P2PDispute.tsx +++ b/web/src/pages/P2PDispute.tsx @@ -30,6 +30,7 @@ import { import { supabase } from '@/lib/supabase'; import { toast } from 'sonner'; import { formatAddress } from '@pezkuwi/utils/formatting'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; interface DisputeDetails { id: string; @@ -105,11 +106,11 @@ export default function P2PDispute() { const navigate = useNavigate(); const { t } = useTranslation(); const fileInputRef = useRef(null); + const { userId } = useP2PIdentity(); const [dispute, setDispute] = useState(null); const [evidence, setEvidence] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [currentUserId, setCurrentUserId] = useState(null); const [isUploading, setIsUploading] = useState(false); const [selectedImage, setSelectedImage] = useState(null); @@ -118,10 +119,6 @@ export default function P2PDispute() { if (!disputeId) return; try { - // Get current user - const { data: { user } } = await supabase.auth.getUser(); - setCurrentUserId(user?.id || null); - // Fetch dispute with trade info const { data: disputeData, error: disputeError } = await supabase .from('p2p_disputes') @@ -199,7 +196,7 @@ export default function P2PDispute() { const handleFileUpload = async (e: React.ChangeEvent) => { const files = e.target.files; - if (!files || files.length === 0 || !dispute || !currentUserId) return; + if (!files || files.length === 0 || !dispute || !userId) return; setIsUploading(true); @@ -224,7 +221,7 @@ export default function P2PDispute() { // Insert evidence record await supabase.from('p2p_dispute_evidence').insert({ dispute_id: dispute.id, - uploaded_by: currentUserId, + uploaded_by: userId, evidence_type: file.type.startsWith('image/') ? 'screenshot' : 'document', file_url: urlData.publicUrl, description: file.name, @@ -244,11 +241,11 @@ export default function P2PDispute() { }; const isParticipant = dispute?.trade && - (dispute.trade.buyer_id === currentUserId || dispute.trade.seller_id === currentUserId); + (dispute.trade.buyer_id === userId || dispute.trade.seller_id === userId); - const isBuyer = dispute?.trade?.buyer_id === currentUserId; - const isSeller = dispute?.trade?.seller_id === currentUserId; - const isOpener = dispute?.opened_by === currentUserId; + const isBuyer = dispute?.trade?.buyer_id === userId; + const isSeller = dispute?.trade?.seller_id === userId; + const isOpener = dispute?.opened_by === userId; if (isLoading) { return ( @@ -444,7 +441,7 @@ export default function P2PDispute() { {evidence.map((item) => { const isImage = item.evidence_type === 'screenshot' || item.file_url.match(/\.(jpg|jpeg|png|gif|webp)$/i); - const isMyEvidence = item.uploaded_by === currentUserId; + const isMyEvidence = item.uploaded_by === userId; return (
(null); const [tierInfo, setTierInfo] = useState(null); @@ -111,19 +113,18 @@ export default function P2PMerchantDashboard() { // Fetch merchant data const fetchData = useCallback(async () => { + if (!userId) { + setLoading(false); + return; + } + setLoading(true); try { - const { data: { user } } = await supabase.auth.getUser(); - if (!user) { - navigate('/login'); - return; - } - // Fetch stats const { data: statsData } = await supabase .from('p2p_merchant_stats') .select('*') - .eq('user_id', user.id) + .eq('user_id', userId) .single(); if (statsData) { @@ -134,7 +135,7 @@ export default function P2PMerchantDashboard() { const { data: tierData } = await supabase .from('p2p_merchant_tiers') .select('tier, max_pending_orders, max_order_amount, featured_ads_allowed') - .eq('user_id', user.id) + .eq('user_id', userId) .single(); if (tierData) { @@ -145,7 +146,7 @@ export default function P2PMerchantDashboard() { const { data: adsData } = await supabase .from('p2p_fiat_offers') .select('*') - .eq('seller_id', user.id) + .eq('seller_id', userId) .in('status', ['open', 'paused']) .order('created_at', { ascending: false }); @@ -160,7 +161,7 @@ export default function P2PMerchantDashboard() { const { data: tradesData } = await supabase .from('p2p_fiat_trades') .select('created_at, fiat_amount, status') - .or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`) + .or(`seller_id.eq.${userId},buyer_id.eq.${userId}`) .gte('created_at', thirtyDaysAgo.toISOString()) .order('created_at', { ascending: true }); @@ -197,7 +198,7 @@ export default function P2PMerchantDashboard() { } finally { setLoading(false); } - }, [navigate]); + }, [userId]); useEffect(() => { fetchData(); @@ -290,13 +291,14 @@ export default function P2PMerchantDashboard() { // Save auto-reply message const saveAutoReply = async () => { + if (!userId) return; setSavingAutoReply(true); try { // Save to all active ads const { error } = await supabase .from('p2p_fiat_offers') .update({ auto_reply_message: autoReplyMessage }) - .eq('seller_id', (await supabase.auth.getUser()).data.user?.id); + .eq('seller_id', userId); if (error) throw error; diff --git a/web/src/pages/P2POrders.tsx b/web/src/pages/P2POrders.tsx index 93c156bf..22691d19 100644 --- a/web/src/pages/P2POrders.tsx +++ b/web/src/pages/P2POrders.tsx @@ -18,7 +18,7 @@ import { RefreshCw, FileText, } from 'lucide-react'; -import { useAuth } from '@/contexts/AuthContext'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { toast } from 'sonner'; import { supabase } from '@/lib/supabase'; import { type P2PFiatTrade, type P2PFiatOffer } from '@shared/lib/p2p-fiat'; @@ -34,7 +34,7 @@ interface TradeWithOffer extends P2PFiatTrade { export default function P2POrders() { const navigate = useNavigate(); const { t } = useTranslation(); - const { user } = useAuth(); + const { userId } = useP2PIdentity(); const [trades, setTrades] = useState([]); const [loading, setLoading] = useState(true); @@ -42,7 +42,7 @@ export default function P2POrders() { // Fetch user's trades const fetchTrades = async () => { - if (!user) { + if (!userId) { setLoading(false); return; } @@ -53,7 +53,7 @@ export default function P2POrders() { const { data: tradesData, error } = await supabase .from('p2p_fiat_trades') .select('*') - .or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`) + .or(`seller_id.eq.${userId},buyer_id.eq.${userId}`) .order('created_at', { ascending: false }); if (error) throw error; @@ -86,7 +86,7 @@ export default function P2POrders() { useEffect(() => { fetchTrades(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user]); + }, [userId]); // Filter trades by status const activeTrades = trades.filter(t => @@ -149,7 +149,7 @@ export default function P2POrders() { // Render trade card const renderTradeCard = (trade: TradeWithOffer) => { - const isBuyer = trade.buyer_id === user?.id; + const isBuyer = trade.buyer_id === userId; const counterpartyWallet = isBuyer ? trade.offer?.seller_wallet || 'Unknown' : trade.buyer_wallet; @@ -245,7 +245,7 @@ export default function P2POrders() {
); - if (!user) { + if (!userId) { return (
@@ -253,7 +253,7 @@ export default function P2POrders() {

{t('p2p.loginRequired')}

{t('p2p.loginToView')}

- +
diff --git a/web/src/pages/P2PTrade.tsx b/web/src/pages/P2PTrade.tsx index 88959194..5715382d 100644 --- a/web/src/pages/P2PTrade.tsx +++ b/web/src/pages/P2PTrade.tsx @@ -33,13 +33,13 @@ import { RefreshCw, Star, } from 'lucide-react'; -import { useAuth } from '@/contexts/AuthContext'; -import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { toast } from 'sonner'; import { supabase } from '@/lib/supabase'; import { markPaymentSent, confirmPaymentReceived, + cancelTrade, getUserReputation, type P2PFiatTrade, type P2PFiatOffer, @@ -74,8 +74,7 @@ export default function P2PTrade() { const { tradeId } = useParams<{ tradeId: string }>(); const navigate = useNavigate(); const { t } = useTranslation(); - const { user } = useAuth(); - const { api, selectedAccount } = usePezkuwi(); + const { userId } = useP2PIdentity(); const [trade, setTrade] = useState(null); const [loading, setLoading] = useState(true); @@ -91,8 +90,8 @@ export default function P2PTrade() { const [cancelReason, setCancelReason] = useState(''); // Determine user role - const isSeller = trade?.seller_id === user?.id; - const isBuyer = trade?.buyer_id === user?.id; + const isSeller = trade?.seller_id === userId; + const isBuyer = trade?.buyer_id === userId; const isParticipant = isSeller || isBuyer; // Fetch trade details @@ -265,7 +264,7 @@ export default function P2PTrade() { // Handle mark as paid const handleMarkAsPaid = async () => { - if (!trade || !user) return; + if (!trade || !userId) return; setActionLoading(true); try { @@ -293,14 +292,14 @@ export default function P2PTrade() { // Handle release crypto const handleReleaseCrypto = async () => { - if (!trade || !api || !selectedAccount) { - toast.error(t('p2p.connectWallet')); + if (!trade || !userId) { + toast.error(t('p2p.connectWalletAndLogin')); return; } setActionLoading(true); try { - await confirmPaymentReceived(api, selectedAccount, trade.id); + await confirmPaymentReceived(trade.id, userId); toast.success(t('p2pTrade.cryptoReleasedToast')); fetchTrade(); } catch (error) { @@ -312,29 +311,11 @@ export default function P2PTrade() { // Handle cancel trade const handleCancelTrade = async () => { - if (!trade) return; + if (!trade || !userId) return; setActionLoading(true); try { - const { error } = await supabase - .from('p2p_fiat_trades') - .update({ - status: 'cancelled', - cancelled_by: user?.id, - cancel_reason: cancelReason, - }) - .eq('id', trade.id); - - if (error) throw error; - - // Restore offer remaining amount - await supabase - .from('p2p_fiat_offers') - .update({ - remaining_amount: (trade.offer?.remaining_amount || 0) + trade.crypto_amount, - status: 'open', - }) - .eq('id', trade.offer_id); + await cancelTrade(trade.id, userId, cancelReason || undefined); setShowCancelModal(false); toast.success(t('p2pTrade.tradeCancelledToast')); diff --git a/web/supabase/functions/verify-deposit/index.ts b/web/supabase/functions/verify-deposit/index.ts index 02c4223e..5416eec2 100644 --- a/web/supabase/functions/verify-deposit/index.ts +++ b/web/supabase/functions/verify-deposit/index.ts @@ -35,10 +35,10 @@ const RPC_WS = 'wss://rpc.pezkuwichain.io' // Token decimals const DECIMALS = 12 -// Generate deterministic UUID v5 from wallet address -async function walletToUUID(walletAddress: string): Promise { +// Generate deterministic UUID v5 from identity ID (citizen number or visa number) +async function identityToUUID(identityId: string): Promise { const NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8' - const data = new TextEncoder().encode(walletAddress) + const data = new TextEncoder().encode(identityId) const namespaceBytes = new Uint8Array(16) const hex = NAMESPACE.replace(/-/g, '') for (let i = 0; i < 16; i++) { @@ -66,6 +66,7 @@ interface DepositRequest { token: 'HEZ' | 'PEZ' expectedAmount: number walletAddress: string + identityId: string blockNumber?: number } @@ -371,11 +372,11 @@ serve(async (req) => { const serviceClient = createClient(supabaseUrl, supabaseServiceKey) const body: DepositRequest = await req.json() - const { txHash, token, expectedAmount, walletAddress, blockNumber } = body + const { txHash, token, expectedAmount, walletAddress, identityId, blockNumber } = body - if (!txHash || !token || !expectedAmount || !walletAddress) { + if (!txHash || !token || !expectedAmount || !walletAddress || !identityId) { return new Response( - JSON.stringify({ success: false, error: 'Missing required fields: txHash, token, expectedAmount, walletAddress' }), + JSON.stringify({ success: false, error: 'Missing required fields: txHash, token, expectedAmount, walletAddress, identityId' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } @@ -414,8 +415,8 @@ serve(async (req) => { } } - // Map wallet address to deterministic UUID - const userId = await walletToUUID(walletAddress) + // Map identity (citizen/visa number) to deterministic UUID + const userId = await identityToUUID(identityId) // Create or update deposit request const { data: depositRequest, error: requestError } = await serviceClient diff --git a/web/supabase/migrations/20260223150000_p2p_visa_system.sql b/web/supabase/migrations/20260223150000_p2p_visa_system.sql new file mode 100644 index 00000000..587ff2a6 --- /dev/null +++ b/web/supabase/migrations/20260223150000_p2p_visa_system.sql @@ -0,0 +1,85 @@ +-- P2P Visa System +-- Provides identity for non-citizen P2P traders +-- Citizens use their on-chain Citizen Number (from NFT) +-- Non-citizens apply for a Visa (off-chain, stored in Supabase) + +CREATE TABLE IF NOT EXISTS public.p2p_visa ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + visa_number TEXT UNIQUE NOT NULL, + wallet_address TEXT UNIQUE NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + trust_level INTEGER NOT NULL DEFAULT 1, + issued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ DEFAULT (now() + interval '1 year'), + metadata JSONB DEFAULT '{}' +); + +CREATE INDEX IF NOT EXISTS idx_visa_wallet ON public.p2p_visa(wallet_address); +CREATE INDEX IF NOT EXISTS idx_visa_status ON public.p2p_visa(status); + +-- Generate unique visa number: V-XXXXXX (6 digits) +CREATE OR REPLACE FUNCTION generate_visa_number() +RETURNS TEXT +LANGUAGE plpgsql +AS $$ +DECLARE + num TEXT; +BEGIN + LOOP + num := 'V-' || lpad(floor(random() * 1000000)::text, 6, '0'); + EXIT WHEN NOT EXISTS (SELECT 1 FROM public.p2p_visa WHERE visa_number = num); + END LOOP; + RETURN num; +END; +$$; + +-- Issue a visa for a wallet address (returns the visa record) +CREATE OR REPLACE FUNCTION issue_p2p_visa(p_wallet_address TEXT) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_visa_number TEXT; + v_result JSONB; +BEGIN + -- Check if wallet already has a visa + IF EXISTS (SELECT 1 FROM public.p2p_visa WHERE wallet_address = p_wallet_address AND status = 'active') THEN + SELECT jsonb_build_object( + 'success', true, + 'visa_number', visa_number, + 'already_exists', true + ) INTO v_result + FROM public.p2p_visa + WHERE wallet_address = p_wallet_address AND status = 'active'; + RETURN v_result; + END IF; + + -- Generate unique visa number + v_visa_number := generate_visa_number(); + + -- Insert new visa + INSERT INTO public.p2p_visa (visa_number, wallet_address) + VALUES (v_visa_number, p_wallet_address); + + RETURN jsonb_build_object( + 'success', true, + 'visa_number', v_visa_number, + 'already_exists', false + ); +END; +$$; + +-- RLS: service role only (P2P operations go through edge functions) +ALTER TABLE public.p2p_visa ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Service role full access on p2p_visa" + ON public.p2p_visa + FOR ALL + USING (auth.role() = 'service_role'); + +-- Allow anon/authenticated to read their own visa by wallet address +CREATE POLICY "Users can read own visa" + ON public.p2p_visa + FOR SELECT + USING (true);