Files
pwap/shared/lib/p2p-fiat.ts
T
pezkuwichain 9bb71c436b fix: update escrow address to Treasury_3
- Update PLATFORM_ESCROW_ADDRESS in p2p-fiat.ts
- Update PLATFORM_WALLET in verify-deposit edge function
- Use controlled wallet for P2P deposits/withdrawals
2026-02-03 16:50:56 +03:00

1154 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 {
token: CryptoToken;
amountCrypto: number;
fiatCurrency: FiatCurrency;
fiatAmount: number;
paymentMethodId: string;
paymentDetails: Record<string, string>;
timeLimitMinutes?: number;
minOrderAmount?: number;
maxOrderAmount?: number;
// NOTE: api and account no longer needed - uses internal ledger
}
export interface AcceptOfferParams {
offerId: string;
buyerWallet: string;
amount?: number; // If partial order
// NOTE: api and account no longer needed - uses internal ledger
}
// =====================================================
// 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 {
token,
amountCrypto,
fiatCurrency,
fiatAmount,
paymentMethodId,
paymentDetails,
timeLimitMinutes = DEFAULT_PAYMENT_DEADLINE_MINUTES,
minOrderAmount,
maxOrderAmount
} = params;
try {
// Get current user
const { data: userData } = await supabase.auth.getUser();
const userId = userData.user?.id;
if (!userId) throw new Error('Not authenticated');
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: '', // No longer needed with internal ledger
token,
amount_crypto: amountCrypto,
fiat_currency: fiatCurrency,
fiat_amount: fiatAmount,
price_per_unit: fiatAmount / amountCrypto,
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'
});
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 : '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, amount } = params;
try {
// 1. Get current user
const { data: user } = await supabase.auth.getUser();
if (!user.user) throw new Error('Not authenticated');
// 2. Get offer to determine amount if not specified
const { data: offer, error: offerError } = await supabase
.from('p2p_fiat_offers')
.select('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', user.user.id)
.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: user.user.id,
p_buyer_wallet: params.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
});
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 : '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): Promise<void> {
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');
// 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'
});
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 : '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>
): Promise<void> {
const { data: user } = await supabase.auth.getUser();
await supabase.from('p2p_audit_log').insert({
user_id: user.user?.id,
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,
});
toast.success('Trade cancelled successfully');
} catch (error: unknown) {
console.error('Cancel trade error:', error);
const message = error instanceof Error ? error.message : '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(): Promise<InternalBalance[]> {
try {
const { data: userData } = await supabase.auth.getUser();
const userId = userData.user?.id;
if (!userId) throw new Error('Not authenticated');
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(token: CryptoToken): Promise<InternalBalance | null> {
const balances = await getInternalBalances();
return balances.find(b => b.token === token) || null;
}
/**
* Request a withdrawal from internal balance to external wallet
* This creates a pending request that will be processed by backend service
*/
export async function requestWithdraw(
token: CryptoToken,
amount: number,
walletAddress: string
): Promise<string> {
try {
const { data: userData } = await supabase.auth.getUser();
const userId = userData.user?.id;
if (!userId) throw new Error('Not authenticated');
// 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 request...');
// Call the database function
const { data, error } = await supabase.rpc('request_withdraw', {
p_user_id: userId,
p_token: token,
p_amount: amount,
p_wallet_address: walletAddress
});
if (error) throw error;
// Parse result
const result = typeof data === 'string' ? JSON.parse(data) : data;
if (!result.success) {
throw new Error(result.error || 'Withdrawal request failed');
}
toast.success(`Withdrawal request submitted! ${amount} ${token} will be sent to your wallet.`);
return result.request_id;
} catch (error: unknown) {
console.error('Request withdraw error:', error);
const message = error instanceof Error ? error.message : 'Withdrawal request failed';
toast.error(message);
throw error;
}
}
/**
* Get user's deposit/withdraw request history
*/
export async function getDepositWithdrawHistory(): Promise<DepositWithdrawRequest[]> {
try {
const { data, error } = await supabase
.from('p2p_deposit_withdraw_requests')
.select('*')
.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;
}