Files
pwap/shared/lib/p2p-fiat.ts
T
pezkuwichain 12d6f4f3fd fix: show actual Supabase error details in P2P toast messages
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.
2026-02-24 05:00:45 +03:00

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;
}