mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-28 18:57:56 +00:00
12d6f4f3fd
PostgrestError is not instanceof Error, so catch blocks were falling through to generic messages. Now extracts .message and .details from Supabase errors for better debugging.
1146 lines
33 KiB
TypeScript
1146 lines
33 KiB
TypeScript
/**
|
|
* P2P Fiat Trading System - Production Grade (OKX-Style Internal Ledger)
|
|
*
|
|
* @module p2p-fiat
|
|
* @description Enterprise-level P2P fiat-to-crypto trading with internal ledger escrow
|
|
*
|
|
* OKX Model Implementation:
|
|
* - Blockchain transactions ONLY occur at deposit/withdraw
|
|
* - P2P trades use internal database balance transfers
|
|
* - No blockchain transactions during actual P2P trading
|
|
*/
|
|
|
|
import { ApiPromise } from '@pezkuwi/api';
|
|
import { InjectedAccountWithMeta } from '@pezkuwi/extension-inject/types';
|
|
import { toast } from 'sonner';
|
|
import { supabase } from '@/lib/supabase';
|
|
|
|
// =====================================================
|
|
// TYPES
|
|
// =====================================================
|
|
|
|
export interface PaymentMethod {
|
|
id: string;
|
|
currency: FiatCurrency;
|
|
country: string;
|
|
method_name: string;
|
|
method_type: 'bank' | 'mobile_payment' | 'cash' | 'crypto_exchange';
|
|
logo_url?: string;
|
|
fields: Record<string, string>;
|
|
validation_rules: Record<string, ValidationRule>;
|
|
min_trade_amount: number;
|
|
max_trade_amount?: number;
|
|
processing_time_minutes: number;
|
|
display_order: number;
|
|
}
|
|
|
|
export interface ValidationRule {
|
|
pattern?: string;
|
|
minLength?: number;
|
|
maxLength?: number;
|
|
required?: boolean;
|
|
}
|
|
|
|
// Fiat currencies including Kurdish Diaspora countries
|
|
export type FiatCurrency =
|
|
| 'TRY' // Turkish Lira (Turkey - 15M+ Kurds)
|
|
| 'IQD' // Iraqi Dinar (Kurdistan Region - 6M+ Kurds)
|
|
| 'IRR' // Iranian Rial (Rojhilat - 8M+ Kurds)
|
|
| 'EUR' // Euro (Germany, France, Netherlands, Belgium, Austria)
|
|
| 'USD' // US Dollar
|
|
| 'GBP' // British Pound (UK - 50K+ Kurds)
|
|
| 'SEK' // Swedish Krona (Sweden - 100K+ Kurds)
|
|
| 'CHF' // Swiss Franc (Switzerland - 30K+ Kurds)
|
|
| 'NOK' // Norwegian Krone (Norway - 30K+ Kurds)
|
|
| 'DKK' // Danish Krone (Denmark - 25K+ Kurds)
|
|
| 'AUD' // Australian Dollar (Australia - 20K+ Kurds)
|
|
| 'CAD'; // Canadian Dollar (Canada - 30K+ Kurds)
|
|
export type CryptoToken = 'HEZ' | 'PEZ';
|
|
|
|
export type OfferStatus = 'open' | 'paused' | 'locked' | 'completed' | 'cancelled';
|
|
export type TradeStatus = 'pending' | 'payment_sent' | 'completed' | 'cancelled' | 'disputed' | 'refunded';
|
|
|
|
export interface P2PFiatOffer {
|
|
id: string;
|
|
seller_id: string;
|
|
seller_wallet: string;
|
|
token: CryptoToken;
|
|
amount_crypto: number;
|
|
fiat_currency: FiatCurrency;
|
|
fiat_amount: number;
|
|
price_per_unit: number;
|
|
payment_method_id: string;
|
|
payment_details_encrypted: string;
|
|
min_order_amount?: number;
|
|
max_order_amount?: number;
|
|
time_limit_minutes: number;
|
|
auto_reply_message?: string;
|
|
min_buyer_completed_trades: number;
|
|
min_buyer_reputation: number;
|
|
status: OfferStatus;
|
|
remaining_amount: number;
|
|
escrow_tx_hash?: string;
|
|
created_at: string;
|
|
expires_at: string;
|
|
}
|
|
|
|
export interface P2PFiatTrade {
|
|
id: string;
|
|
offer_id: string;
|
|
seller_id: string;
|
|
buyer_id: string;
|
|
buyer_wallet: string;
|
|
crypto_amount: number;
|
|
fiat_amount: number;
|
|
price_per_unit: number;
|
|
escrow_locked_amount: number;
|
|
buyer_marked_paid_at?: string;
|
|
buyer_payment_proof_url?: string;
|
|
seller_confirmed_at?: string;
|
|
status: TradeStatus;
|
|
payment_deadline: string;
|
|
confirmation_deadline?: string;
|
|
created_at: string;
|
|
completed_at?: string;
|
|
}
|
|
|
|
export interface P2PReputation {
|
|
user_id: string;
|
|
total_trades: number;
|
|
completed_trades: number;
|
|
cancelled_trades: number;
|
|
disputed_trades: number;
|
|
reputation_score: number;
|
|
trust_level: 'new' | 'basic' | 'intermediate' | 'advanced' | 'verified';
|
|
verified_merchant: boolean;
|
|
avg_payment_time_minutes?: number;
|
|
avg_confirmation_time_minutes?: number;
|
|
}
|
|
|
|
export interface CreateOfferParams {
|
|
userId: string;
|
|
sellerWallet: string;
|
|
token: CryptoToken;
|
|
amountCrypto: number;
|
|
fiatCurrency: FiatCurrency;
|
|
fiatAmount: number;
|
|
paymentMethodId: string;
|
|
paymentDetails: Record<string, string>;
|
|
timeLimitMinutes?: number;
|
|
minOrderAmount?: number;
|
|
maxOrderAmount?: number;
|
|
adType?: 'buy' | 'sell';
|
|
}
|
|
|
|
export interface AcceptOfferParams {
|
|
offerId: string;
|
|
buyerUserId: string;
|
|
buyerWallet: string;
|
|
amount?: number; // If partial order
|
|
}
|
|
|
|
// =====================================================
|
|
// INTERNAL BALANCE TYPES (OKX-Style)
|
|
// =====================================================
|
|
|
|
export interface InternalBalance {
|
|
token: CryptoToken;
|
|
available_balance: number;
|
|
locked_balance: number;
|
|
total_balance: number;
|
|
total_deposited: number;
|
|
total_withdrawn: number;
|
|
}
|
|
|
|
export interface DepositWithdrawRequest {
|
|
id: string;
|
|
user_id: string;
|
|
request_type: 'deposit' | 'withdraw';
|
|
token: CryptoToken;
|
|
amount: number;
|
|
wallet_address: string;
|
|
blockchain_tx_hash?: string;
|
|
status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
|
processed_at?: string;
|
|
error_message?: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface BalanceTransaction {
|
|
id: string;
|
|
user_id: string;
|
|
token: string;
|
|
transaction_type: 'deposit' | 'withdraw' | 'escrow_lock' | 'escrow_release' | 'escrow_refund' | 'trade_receive' | 'admin_adjustment';
|
|
amount: number;
|
|
balance_before: number;
|
|
balance_after: number;
|
|
reference_type?: string;
|
|
reference_id?: string;
|
|
description?: string;
|
|
created_at: string;
|
|
}
|
|
|
|
// =====================================================
|
|
// CONSTANTS
|
|
// =====================================================
|
|
|
|
const PLATFORM_ESCROW_ADDRESS = '5H18ZZBU4LwPYbeEZ1JBGvibCU2edhhM8HNUtFi7GgC36CgS';
|
|
|
|
const ASSET_IDS = {
|
|
HEZ: null, // Native token
|
|
PEZ: 1
|
|
} as const;
|
|
|
|
const DEFAULT_PAYMENT_DEADLINE_MINUTES = 30;
|
|
const DEFAULT_CONFIRMATION_DEADLINE_MINUTES = 60;
|
|
|
|
// =====================================================
|
|
// PAYMENT METHODS
|
|
// =====================================================
|
|
|
|
/**
|
|
* Fetch available payment methods for a currency
|
|
*/
|
|
export async function getPaymentMethods(currency: FiatCurrency): Promise<PaymentMethod[]> {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('payment_methods')
|
|
.select('*')
|
|
.eq('currency', currency)
|
|
.eq('is_active', true)
|
|
.order('display_order');
|
|
|
|
if (error) throw error;
|
|
return data || [];
|
|
} catch (error) {
|
|
console.error('Get payment methods error:', error);
|
|
toast.error('Failed to load payment methods');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate payment details against method rules
|
|
*/
|
|
export function validatePaymentDetails(
|
|
paymentDetails: Record<string, string>,
|
|
validationRules: Record<string, ValidationRule>
|
|
): { valid: boolean; errors: Record<string, string> } {
|
|
const errors: Record<string, string> = {};
|
|
|
|
for (const [field, rules] of Object.entries(validationRules)) {
|
|
const value = paymentDetails[field] || '';
|
|
|
|
if (rules.required && !value) {
|
|
errors[field] = 'This field is required';
|
|
continue;
|
|
}
|
|
|
|
if (rules.pattern && value) {
|
|
const regex = new RegExp(rules.pattern);
|
|
if (!regex.test(value)) {
|
|
errors[field] = 'Invalid format';
|
|
}
|
|
}
|
|
|
|
if (rules.minLength && value.length < rules.minLength) {
|
|
errors[field] = `Minimum ${rules.minLength} characters`;
|
|
}
|
|
|
|
if (rules.maxLength && value.length > rules.maxLength) {
|
|
errors[field] = `Maximum ${rules.maxLength} characters`;
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: Object.keys(errors).length === 0,
|
|
errors
|
|
};
|
|
}
|
|
|
|
// =====================================================
|
|
// ENCRYPTION (AES-256-GCM - OKX-Level Security)
|
|
// =====================================================
|
|
|
|
const ENCRYPTION_KEY_LENGTH = 32; // 256 bits
|
|
const IV_LENGTH = 12; // 96 bits for GCM
|
|
const TAG_LENGTH = 16; // 128 bits
|
|
|
|
/**
|
|
* Derive encryption key from a password/secret
|
|
* In production, this should use a server-side secret
|
|
*/
|
|
async function getEncryptionKey(): Promise<CryptoKey> {
|
|
// Use a combination of user-specific and app-wide secret
|
|
// In production, this should be fetched from secure storage
|
|
const encoder = new TextEncoder();
|
|
const keyMaterial = await crypto.subtle.importKey(
|
|
'raw',
|
|
encoder.encode('p2p-payment-encryption-v1-pezkuwi'),
|
|
'PBKDF2',
|
|
false,
|
|
['deriveBits', 'deriveKey']
|
|
);
|
|
|
|
return crypto.subtle.deriveKey(
|
|
{
|
|
name: 'PBKDF2',
|
|
salt: encoder.encode('pezkuwi-p2p-salt'),
|
|
iterations: 100000,
|
|
hash: 'SHA-256'
|
|
},
|
|
keyMaterial,
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false,
|
|
['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Encrypt payment details using AES-256-GCM
|
|
*/
|
|
async function encryptPaymentDetails(details: Record<string, string>): Promise<string> {
|
|
try {
|
|
const key = await getEncryptionKey();
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(JSON.stringify(details));
|
|
|
|
// Generate random IV
|
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
|
|
// Encrypt
|
|
const encrypted = await crypto.subtle.encrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
key,
|
|
data
|
|
);
|
|
|
|
// Combine IV + ciphertext and encode as base64
|
|
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
|
combined.set(iv);
|
|
combined.set(new Uint8Array(encrypted), iv.length);
|
|
|
|
return btoa(String.fromCharCode(...combined));
|
|
} catch (error) {
|
|
console.error('Encryption failed:', error);
|
|
// Fallback to base64 for backwards compatibility (temporary)
|
|
return btoa(JSON.stringify(details));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decrypt payment details using AES-256-GCM
|
|
*/
|
|
async function decryptPaymentDetails(encrypted: string): Promise<Record<string, string>> {
|
|
try {
|
|
const key = await getEncryptionKey();
|
|
|
|
// Decode base64
|
|
const combined = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
|
|
|
|
// Extract IV and ciphertext
|
|
const iv = combined.slice(0, IV_LENGTH);
|
|
const ciphertext = combined.slice(IV_LENGTH);
|
|
|
|
// Decrypt
|
|
const decrypted = await crypto.subtle.decrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
key,
|
|
ciphertext
|
|
);
|
|
|
|
const decoder = new TextDecoder();
|
|
return JSON.parse(decoder.decode(decrypted));
|
|
} catch {
|
|
// Fallback: try to decode as plain base64 (for old data)
|
|
try {
|
|
return JSON.parse(atob(encrypted));
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
}
|
|
|
|
// =====================================================
|
|
// CREATE OFFER
|
|
// =====================================================
|
|
|
|
/**
|
|
* Create a new P2P fiat offer (OKX-Style Internal Ledger)
|
|
*
|
|
* Steps:
|
|
* 1. Lock crypto from internal balance (NO blockchain tx)
|
|
* 2. Create offer record in Supabase
|
|
* 3. Update escrow tracking
|
|
*
|
|
* NOTE: Blockchain transactions only occur at deposit/withdraw
|
|
*/
|
|
export async function createFiatOffer(params: CreateOfferParams): Promise<string> {
|
|
const {
|
|
userId,
|
|
sellerWallet,
|
|
token,
|
|
amountCrypto,
|
|
fiatCurrency,
|
|
fiatAmount,
|
|
paymentMethodId,
|
|
paymentDetails,
|
|
timeLimitMinutes = DEFAULT_PAYMENT_DEADLINE_MINUTES,
|
|
minOrderAmount,
|
|
maxOrderAmount,
|
|
adType = 'sell'
|
|
} = params;
|
|
|
|
try {
|
|
if (!userId) throw new Error('Identity required for P2P trading');
|
|
|
|
toast.info('Locking crypto from your balance...');
|
|
|
|
// 1. Lock crypto from internal balance (NO blockchain tx!)
|
|
const { data: lockResult, error: lockError } = await supabase.rpc('lock_escrow_internal', {
|
|
p_user_id: userId,
|
|
p_token: token,
|
|
p_amount: amountCrypto
|
|
});
|
|
|
|
if (lockError) throw lockError;
|
|
|
|
// Parse result
|
|
const lockResponse = typeof lockResult === 'string' ? JSON.parse(lockResult) : lockResult;
|
|
|
|
if (!lockResponse.success) {
|
|
throw new Error(lockResponse.error || 'Failed to lock balance');
|
|
}
|
|
|
|
toast.success('Balance locked successfully');
|
|
|
|
// 2. Encrypt payment details (AES-256-GCM)
|
|
const encryptedDetails = await encryptPaymentDetails(paymentDetails);
|
|
|
|
// 3. Create offer in Supabase
|
|
const { data: offer, error: offerError } = await supabase
|
|
.from('p2p_fiat_offers')
|
|
.insert({
|
|
seller_id: userId,
|
|
seller_wallet: sellerWallet,
|
|
ad_type: adType,
|
|
token,
|
|
amount_crypto: amountCrypto,
|
|
fiat_currency: fiatCurrency,
|
|
fiat_amount: fiatAmount,
|
|
payment_method_id: paymentMethodId,
|
|
payment_details_encrypted: encryptedDetails,
|
|
min_order_amount: minOrderAmount,
|
|
max_order_amount: maxOrderAmount,
|
|
time_limit_minutes: timeLimitMinutes,
|
|
status: 'open',
|
|
remaining_amount: amountCrypto,
|
|
escrow_locked_at: new Date().toISOString()
|
|
// NOTE: No escrow_tx_hash - internal ledger doesn't use blockchain during trades
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (offerError) throw offerError;
|
|
|
|
// 4. Update the lock with offer reference
|
|
try {
|
|
await supabase.rpc('lock_escrow_internal', {
|
|
p_user_id: userId,
|
|
p_token: token,
|
|
p_amount: 0, // Just updating reference, not locking more
|
|
p_reference_type: 'offer',
|
|
p_reference_id: offer.id
|
|
});
|
|
} catch {
|
|
// Non-critical, just for tracking
|
|
}
|
|
|
|
// 5. Audit log
|
|
await logAction('offer', offer.id, 'create_offer', {
|
|
token,
|
|
amount_crypto: amountCrypto,
|
|
fiat_currency: fiatCurrency,
|
|
fiat_amount: fiatAmount,
|
|
escrow_type: 'internal_ledger'
|
|
}, userId);
|
|
|
|
toast.success(`Offer created! Selling ${amountCrypto} ${token} for ${fiatAmount} ${fiatCurrency}`);
|
|
|
|
return offer.id;
|
|
} catch (error: unknown) {
|
|
console.error('Create offer error:', error);
|
|
const message = error instanceof Error ? error.message : (error as any)?.message || (error as any)?.details || 'Failed to create offer';
|
|
toast.error(message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// =====================================================
|
|
// ACCEPT OFFER
|
|
// =====================================================
|
|
|
|
/**
|
|
* Accept a P2P fiat offer (buyer)
|
|
*/
|
|
export async function acceptFiatOffer(params: AcceptOfferParams): Promise<string> {
|
|
const { offerId, buyerUserId, buyerWallet, amount } = params;
|
|
|
|
try {
|
|
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
|
|
.from('p2p_fiat_offers')
|
|
.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');
|
|
|
|
const tradeAmount = amount || offer.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', buyerUserId)
|
|
.single();
|
|
|
|
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`);
|
|
}
|
|
}
|
|
|
|
// 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: buyerUserId,
|
|
p_buyer_wallet: buyerWallet,
|
|
p_amount: tradeAmount
|
|
});
|
|
|
|
if (rpcError) throw rpcError;
|
|
|
|
// Parse result (may be string or object depending on Supabase version)
|
|
const response = typeof result === 'string' ? JSON.parse(result) : result;
|
|
|
|
if (!response.success) {
|
|
throw new Error(response.error || 'Failed to accept offer');
|
|
}
|
|
|
|
// 5. Audit log
|
|
await logAction('trade', response.trade_id, 'accept_offer', {
|
|
offer_id: offerId,
|
|
crypto_amount: response.crypto_amount,
|
|
fiat_amount: response.fiat_amount
|
|
}, buyerUserId);
|
|
|
|
toast.success('Trade started! Send payment within time limit.');
|
|
|
|
return response.trade_id;
|
|
} catch (error: unknown) {
|
|
console.error('Accept offer error:', error);
|
|
const message = error instanceof Error
|
|
? error.message
|
|
: (error as any)?.message || (error as any)?.details || JSON.stringify(error) || 'Failed to accept offer';
|
|
toast.error(message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// =====================================================
|
|
// MARK PAYMENT SENT (Buyer)
|
|
// =====================================================
|
|
|
|
/**
|
|
* Buyer marks payment as sent
|
|
*/
|
|
export async function markPaymentSent(
|
|
tradeId: string,
|
|
paymentProofFile?: File
|
|
): Promise<void> {
|
|
try {
|
|
let paymentProofUrl: string | undefined;
|
|
|
|
// 1. Upload payment proof to IPFS if provided
|
|
if (paymentProofFile) {
|
|
const { uploadToIPFS } = await import('./ipfs');
|
|
paymentProofUrl = await uploadToIPFS(paymentProofFile);
|
|
}
|
|
|
|
// 2. Update trade
|
|
const confirmationDeadline = new Date(Date.now() + DEFAULT_CONFIRMATION_DEADLINE_MINUTES * 60 * 1000);
|
|
|
|
const { error } = await supabase
|
|
.from('p2p_fiat_trades')
|
|
.update({
|
|
buyer_marked_paid_at: new Date().toISOString(),
|
|
buyer_payment_proof_url: paymentProofUrl,
|
|
status: 'payment_sent',
|
|
confirmation_deadline: confirmationDeadline.toISOString()
|
|
})
|
|
.eq('id', tradeId);
|
|
|
|
if (error) throw error;
|
|
|
|
// 3. Notify seller (push notification would go here)
|
|
|
|
// 4. Audit log
|
|
await logAction('trade', tradeId, 'mark_payment_sent', {
|
|
payment_proof_url: paymentProofUrl
|
|
});
|
|
|
|
toast.success('Payment marked as sent. Waiting for seller confirmation...');
|
|
} catch (error: any) {
|
|
console.error('Mark payment sent error:', error);
|
|
toast.error(error.message || 'Failed to mark payment as sent');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// =====================================================
|
|
// CONFIRM PAYMENT RECEIVED (Seller)
|
|
// =====================================================
|
|
|
|
/**
|
|
* Seller confirms payment received and releases crypto (OKX-Style Internal Ledger)
|
|
*
|
|
* This function transfers crypto from seller's locked balance to buyer's available balance.
|
|
* NO blockchain transaction occurs - just database update.
|
|
*
|
|
* Buyer can later withdraw to external wallet if needed (separate blockchain tx).
|
|
*/
|
|
export async function confirmPaymentReceived(tradeId: string, sellerId: string): Promise<void> {
|
|
try {
|
|
if (!sellerId) throw new Error('Identity required for P2P trading');
|
|
|
|
// 2. Get trade details
|
|
const { data: trade, error: tradeError } = await supabase
|
|
.from('p2p_fiat_trades')
|
|
.select('*')
|
|
.eq('id', tradeId)
|
|
.single();
|
|
|
|
if (tradeError) throw tradeError;
|
|
if (!trade) throw new Error('Trade not found');
|
|
|
|
// Verify caller is the seller
|
|
if (trade.seller_id !== sellerId) {
|
|
throw new Error('Only seller can confirm payment');
|
|
}
|
|
|
|
if (trade.status !== 'payment_sent') {
|
|
throw new Error('Payment has not been marked as sent');
|
|
}
|
|
|
|
// 3. Get offer to get token type
|
|
const { data: offer } = await supabase
|
|
.from('p2p_fiat_offers')
|
|
.select('token')
|
|
.eq('id', trade.offer_id)
|
|
.single();
|
|
|
|
if (!offer) throw new Error('Offer not found');
|
|
|
|
toast.info('Releasing crypto to buyer...');
|
|
|
|
// 4. Release escrow internally (NO blockchain tx!)
|
|
// This transfers from seller's locked_balance to buyer's available_balance
|
|
const { data: releaseResult, error: releaseError } = await supabase.rpc('release_escrow_internal', {
|
|
p_from_user_id: trade.seller_id,
|
|
p_to_user_id: trade.buyer_id,
|
|
p_token: offer.token,
|
|
p_amount: trade.crypto_amount,
|
|
p_reference_type: 'trade',
|
|
p_reference_id: tradeId
|
|
});
|
|
|
|
if (releaseError) throw releaseError;
|
|
|
|
// Parse result
|
|
const releaseResponse = typeof releaseResult === 'string' ? JSON.parse(releaseResult) : releaseResult;
|
|
|
|
if (!releaseResponse.success) {
|
|
throw new Error(releaseResponse.error || 'Failed to release escrow');
|
|
}
|
|
|
|
// 5. Update trade status
|
|
const { error: updateError } = await supabase
|
|
.from('p2p_fiat_trades')
|
|
.update({
|
|
seller_confirmed_at: new Date().toISOString(),
|
|
escrow_released_at: new Date().toISOString(),
|
|
status: 'completed',
|
|
completed_at: new Date().toISOString()
|
|
// NOTE: No escrow_release_tx_hash - internal ledger doesn't use blockchain during trades
|
|
})
|
|
.eq('id', tradeId);
|
|
|
|
if (updateError) throw updateError;
|
|
|
|
// 6. Update reputations
|
|
await updateReputations(trade.seller_id, trade.buyer_id, tradeId);
|
|
|
|
// 7. Audit log
|
|
await logAction('trade', tradeId, 'confirm_payment', {
|
|
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) {
|
|
console.error('Confirm payment error:', error);
|
|
const message = error instanceof Error ? error.message : (error as any)?.message || (error as any)?.details || 'Failed to confirm payment';
|
|
toast.error(message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// =====================================================
|
|
// HELPER FUNCTIONS
|
|
// =====================================================
|
|
|
|
async function signAndSendTx(
|
|
api: ApiPromise,
|
|
account: InjectedAccountWithMeta,
|
|
tx: any
|
|
): Promise<string> {
|
|
// Get signer from Polkadot.js extension
|
|
const { web3FromSource } = await import('@pezkuwi/extension-dapp');
|
|
const injector = await web3FromSource(account.meta.source);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let unsub: () => void;
|
|
|
|
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 (status.isInBlock || status.isFinalized) {
|
|
resolve(txHash.toString());
|
|
if (unsub) unsub();
|
|
}
|
|
}
|
|
).then((unsubscribe: () => void) => {
|
|
unsub = unsubscribe;
|
|
}).catch(reject);
|
|
});
|
|
}
|
|
|
|
// NOTE: signAndSendWithPlatformKey removed - OKX-style internal ledger
|
|
// doesn't need blockchain transactions during P2P trades.
|
|
// Blockchain transactions only occur at deposit/withdraw via backend service.
|
|
|
|
async function updateReputations(sellerId: string, buyerId: string, tradeId: string): Promise<void> {
|
|
await supabase.rpc('update_p2p_reputation', {
|
|
p_seller_id: sellerId,
|
|
p_buyer_id: buyerId,
|
|
p_trade_id: tradeId
|
|
});
|
|
}
|
|
|
|
async function logAction(
|
|
entityType: string,
|
|
entityId: string,
|
|
action: string,
|
|
details: Record<string, any>,
|
|
userId?: string
|
|
): Promise<void> {
|
|
await supabase.from('p2p_audit_log').insert({
|
|
user_id: userId || null,
|
|
action,
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
details
|
|
});
|
|
}
|
|
|
|
// =====================================================
|
|
// QUERY FUNCTIONS
|
|
// =====================================================
|
|
|
|
export async function getActiveOffers(
|
|
currency?: FiatCurrency,
|
|
token?: CryptoToken
|
|
): Promise<P2PFiatOffer[]> {
|
|
try {
|
|
let query = supabase
|
|
.from('p2p_fiat_offers')
|
|
.select('*')
|
|
.eq('status', 'open')
|
|
.gt('remaining_amount', 0)
|
|
.gt('expires_at', new Date().toISOString())
|
|
.order('price_per_unit');
|
|
|
|
if (currency) query = query.eq('fiat_currency', currency);
|
|
if (token) query = query.eq('token', token);
|
|
|
|
const { data, error } = await query;
|
|
if (error) throw error;
|
|
|
|
return data || [];
|
|
} catch (error) {
|
|
console.error('Get active offers error:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getUserTrades(userId: string): Promise<P2PFiatTrade[]> {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('p2p_fiat_trades')
|
|
.select('*')
|
|
.or(`seller_id.eq.${userId},buyer_id.eq.${userId}`)
|
|
.order('created_at', { ascending: false });
|
|
|
|
if (error) throw error;
|
|
return data || [];
|
|
} catch (error) {
|
|
console.error('Get user trades error:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getUserReputation(userId: string): Promise<P2PReputation | null> {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('p2p_reputation')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Get user reputation error:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a specific trade by ID
|
|
*/
|
|
export async function getTradeById(tradeId: string): Promise<P2PFiatTrade | null> {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('p2p_fiat_trades')
|
|
.select('*')
|
|
.eq('id', tradeId)
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Get trade by ID error:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel a trade (buyer only, before payment sent)
|
|
*/
|
|
export async function cancelTrade(
|
|
tradeId: string,
|
|
cancelledBy: string,
|
|
reason?: string
|
|
): Promise<void> {
|
|
try {
|
|
// 1. Get trade details
|
|
const { data: trade, error: tradeError } = await supabase
|
|
.from('p2p_fiat_trades')
|
|
.select('*')
|
|
.eq('id', tradeId)
|
|
.single();
|
|
|
|
if (tradeError) throw tradeError;
|
|
if (!trade) throw new Error('Trade not found');
|
|
|
|
// Only allow cancellation if pending
|
|
if (trade.status !== 'pending') {
|
|
throw new Error('Trade cannot be cancelled at this stage');
|
|
}
|
|
|
|
// 2. Update trade status
|
|
const { error: updateError } = await supabase
|
|
.from('p2p_fiat_trades')
|
|
.update({
|
|
status: 'cancelled',
|
|
cancelled_by: cancelledBy,
|
|
cancel_reason: reason,
|
|
})
|
|
.eq('id', tradeId);
|
|
|
|
if (updateError) throw updateError;
|
|
|
|
// 3. Restore offer remaining amount
|
|
const { data: offer } = await supabase
|
|
.from('p2p_fiat_offers')
|
|
.select('remaining_amount')
|
|
.eq('id', trade.offer_id)
|
|
.single();
|
|
|
|
if (offer) {
|
|
await supabase
|
|
.from('p2p_fiat_offers')
|
|
.update({
|
|
remaining_amount: offer.remaining_amount + trade.crypto_amount,
|
|
status: 'open',
|
|
})
|
|
.eq('id', trade.offer_id);
|
|
}
|
|
|
|
// 4. Audit log
|
|
await logAction('trade', tradeId, 'cancel_trade', {
|
|
cancelled_by: cancelledBy,
|
|
reason,
|
|
}, cancelledBy);
|
|
|
|
toast.success('Trade cancelled successfully');
|
|
} catch (error: unknown) {
|
|
console.error('Cancel trade error:', error);
|
|
const message = error instanceof Error ? error.message : (error as any)?.message || (error as any)?.details || 'Failed to cancel trade';
|
|
toast.error(message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update user reputation after trade completion
|
|
*/
|
|
export async function updateUserReputation(
|
|
userId: string,
|
|
tradeCompleted: boolean
|
|
): Promise<void> {
|
|
try {
|
|
// Get current reputation
|
|
const { data: currentRep } = await supabase
|
|
.from('p2p_reputation')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
.single();
|
|
|
|
if (currentRep) {
|
|
// Update existing reputation
|
|
await supabase
|
|
.from('p2p_reputation')
|
|
.update({
|
|
total_trades: currentRep.total_trades + 1,
|
|
completed_trades: tradeCompleted
|
|
? currentRep.completed_trades + 1
|
|
: currentRep.completed_trades,
|
|
cancelled_trades: tradeCompleted
|
|
? currentRep.cancelled_trades
|
|
: currentRep.cancelled_trades + 1,
|
|
reputation_score: tradeCompleted
|
|
? Math.min(100, currentRep.reputation_score + 1)
|
|
: Math.max(0, currentRep.reputation_score - 2),
|
|
})
|
|
.eq('user_id', userId);
|
|
} else {
|
|
// Create new reputation record
|
|
await supabase.from('p2p_reputation').insert({
|
|
user_id: userId,
|
|
total_trades: 1,
|
|
completed_trades: tradeCompleted ? 1 : 0,
|
|
cancelled_trades: tradeCompleted ? 0 : 1,
|
|
reputation_score: tradeCompleted ? 50 : 48,
|
|
trust_level: 'new',
|
|
verified_merchant: false,
|
|
fast_trader: false,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Update reputation error:', error);
|
|
}
|
|
}
|
|
|
|
// =====================================================
|
|
// INTERNAL BALANCE FUNCTIONS (OKX-Style)
|
|
// =====================================================
|
|
|
|
/**
|
|
* Get user's internal balances for P2P trading
|
|
*/
|
|
export async function getInternalBalances(userId: string): Promise<InternalBalance[]> {
|
|
try {
|
|
if (!userId) throw new Error('Identity required for P2P trading');
|
|
|
|
const { data, error } = await supabase.rpc('get_user_internal_balance', {
|
|
p_user_id: userId
|
|
});
|
|
|
|
if (error) throw error;
|
|
|
|
// Parse the JSON result
|
|
const balances = typeof data === 'string' ? JSON.parse(data) : data;
|
|
return balances || [];
|
|
} catch (error) {
|
|
console.error('Get internal balances error:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user's internal balance for a specific token
|
|
*/
|
|
export async function getInternalBalance(userId: string, token: CryptoToken): Promise<InternalBalance | null> {
|
|
const balances = await getInternalBalances(userId);
|
|
return balances.find(b => b.token === token) || null;
|
|
}
|
|
|
|
/**
|
|
* Request a withdrawal from internal balance to external wallet
|
|
* Calls the process-withdraw edge function which handles the full flow:
|
|
* limit check → lock balance → blockchain TX → complete withdrawal
|
|
*/
|
|
export async function requestWithdraw(
|
|
userId: string,
|
|
token: CryptoToken,
|
|
amount: number,
|
|
walletAddress: string
|
|
): Promise<string> {
|
|
try {
|
|
if (!userId) throw new Error('Identity required for P2P trading');
|
|
|
|
// Validate amount
|
|
if (amount <= 0) throw new Error('Amount must be greater than 0');
|
|
|
|
// Validate wallet address (basic check)
|
|
if (!walletAddress || walletAddress.length < 40) {
|
|
throw new Error('Invalid wallet address');
|
|
}
|
|
|
|
toast.info('Processing withdrawal...');
|
|
|
|
const { data, error } = await supabase.functions.invoke('process-withdraw', {
|
|
body: { userId, token, amount, walletAddress }
|
|
});
|
|
|
|
if (error) throw error;
|
|
|
|
if (!data?.success) {
|
|
throw new Error(data?.error || 'Withdrawal failed');
|
|
}
|
|
|
|
toast.success(`${amount} ${token} sent to your wallet! TX: ${data.txHash?.slice(0, 10)}...`);
|
|
|
|
return data.txHash || '';
|
|
} catch (error: unknown) {
|
|
console.error('Request withdraw error:', error);
|
|
const message = error instanceof Error ? error.message : (error as any)?.message || (error as any)?.details || 'Withdrawal failed';
|
|
toast.error(message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user's deposit/withdraw request history
|
|
*/
|
|
export async function getDepositWithdrawHistory(userId: string): Promise<DepositWithdrawRequest[]> {
|
|
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);
|
|
|
|
if (error) throw error;
|
|
return data || [];
|
|
} catch (error) {
|
|
console.error('Get deposit/withdraw history error:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user's balance transaction history
|
|
*/
|
|
export async function getBalanceTransactionHistory(limit: number = 50): Promise<BalanceTransaction[]> {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('p2p_balance_transactions')
|
|
.select('*')
|
|
.order('created_at', { ascending: false })
|
|
.limit(limit);
|
|
|
|
if (error) throw error;
|
|
return data || [];
|
|
} catch (error) {
|
|
console.error('Get balance transaction history error:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get platform escrow wallet address (for deposits)
|
|
*/
|
|
export async function getPlatformWalletAddress(): Promise<string> {
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('p2p_config')
|
|
.select('value')
|
|
.eq('key', 'platform_escrow_wallet')
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
return data?.value || PLATFORM_ESCROW_ADDRESS;
|
|
} catch (error) {
|
|
console.error('Get platform wallet address error:', error);
|
|
return PLATFORM_ESCROW_ADDRESS;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @deprecated DO NOT USE - Use Edge Function 'verify-deposit' instead
|
|
*
|
|
* This function is DISABLED for security reasons.
|
|
* Deposits must be verified through the backend Edge Function which:
|
|
* 1. Verifies the transaction exists on-chain
|
|
* 2. Confirms the recipient is the platform wallet
|
|
* 3. Validates the amount matches
|
|
* 4. Prevents duplicate processing
|
|
*
|
|
* Usage:
|
|
* ```typescript
|
|
* const { data, error } = await supabase.functions.invoke('verify-deposit', {
|
|
* body: { txHash, token, expectedAmount }
|
|
* });
|
|
* ```
|
|
*/
|
|
export async function verifyDeposit(
|
|
_txHash: string,
|
|
_token: CryptoToken,
|
|
_amount: number
|
|
): Promise<boolean> {
|
|
console.error(
|
|
'[SECURITY] verifyDeposit() is disabled. Use Edge Function "verify-deposit" instead.'
|
|
);
|
|
toast.error('Please use the secure verification method. Contact support if this persists.');
|
|
return false;
|
|
}
|