mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-06-12 21:21:02 +00:00
feat(p2p): implement OKX-style internal ledger escrow system
Phase 5 implementation - Internal Ledger Escrow (OKX Model): - No blockchain transactions during P2P trades - Blockchain tx only at deposit/withdraw - Fast and fee-free P2P trading Database: - Add user_internal_balances table - Add p2p_deposit_withdraw_requests table - Add p2p_balance_transactions table - Add lock_escrow_internal(), release_escrow_internal() functions - Add process_deposit(), request_withdraw() functions UI Components: - Add InternalBalanceCard showing available/locked balances - Add DepositModal for crypto deposits to P2P balance - Add WithdrawModal for withdrawals from P2P balance - Integrate balance card into P2PDashboard Backend: - Add process-withdrawal Edge Function - Add verify-deposit Edge Function Updated p2p-fiat.ts: - createFiatOffer() uses internal balance lock - confirmPaymentReceived() uses internal balance transfer - Add internal balance management functions
This commit is contained in:
+343
-86
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* P2P Fiat Trading System - Production Grade
|
||||
*
|
||||
* P2P Fiat Trading System - Production Grade (OKX-Style Internal Ledger)
|
||||
*
|
||||
* @module p2p-fiat
|
||||
* @description Enterprise-level P2P fiat-to-crypto trading with escrow
|
||||
* @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 '@polkadot/api';
|
||||
@@ -100,8 +105,6 @@ export interface P2PReputation {
|
||||
}
|
||||
|
||||
export interface CreateOfferParams {
|
||||
api: ApiPromise;
|
||||
account: InjectedAccountWithMeta;
|
||||
token: CryptoToken;
|
||||
amountCrypto: number;
|
||||
fiatCurrency: FiatCurrency;
|
||||
@@ -111,13 +114,55 @@ export interface CreateOfferParams {
|
||||
timeLimitMinutes?: number;
|
||||
minOrderAmount?: number;
|
||||
maxOrderAmount?: number;
|
||||
// NOTE: api and account no longer needed - uses internal ledger
|
||||
}
|
||||
|
||||
export interface AcceptOfferParams {
|
||||
api: ApiPromise;
|
||||
account: InjectedAccountWithMeta;
|
||||
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;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
@@ -222,17 +267,17 @@ function decryptPaymentDetails(encrypted: string): Record<string, string> {
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Create a new P2P fiat offer
|
||||
*
|
||||
* Create a new P2P fiat offer (OKX-Style Internal Ledger)
|
||||
*
|
||||
* Steps:
|
||||
* 1. Lock crypto in platform escrow (blockchain tx)
|
||||
* 1. Lock crypto from internal balance (NO blockchain tx)
|
||||
* 2. Create offer record in Supabase
|
||||
* 3. Update escrow balance tracking
|
||||
* 3. Update escrow tracking
|
||||
*
|
||||
* NOTE: Blockchain transactions only occur at deposit/withdraw
|
||||
*/
|
||||
export async function createFiatOffer(params: CreateOfferParams): Promise<string> {
|
||||
const {
|
||||
api,
|
||||
account,
|
||||
token,
|
||||
amountCrypto,
|
||||
fiatCurrency,
|
||||
@@ -245,24 +290,30 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
|
||||
} = params;
|
||||
|
||||
try {
|
||||
// 1. Lock crypto in escrow (blockchain)
|
||||
toast.info('Locking crypto in escrow...');
|
||||
|
||||
const amount = BigInt(amountCrypto * 1e12); // Convert to Planck
|
||||
|
||||
let txHash: string;
|
||||
if (token === 'HEZ') {
|
||||
// Native token transfer
|
||||
const tx = api.tx.balances.transfer(PLATFORM_ESCROW_ADDRESS, amount);
|
||||
txHash = await signAndSendTx(api, account, tx);
|
||||
} else {
|
||||
// Asset transfer (PEZ)
|
||||
const assetId = ASSET_IDS[token];
|
||||
const tx = api.tx.assets.transfer(assetId, PLATFORM_ESCROW_ADDRESS, amount);
|
||||
txHash = await signAndSendTx(api, account, tx);
|
||||
// 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('Crypto locked in escrow');
|
||||
toast.success('Balance locked successfully');
|
||||
|
||||
// 2. Encrypt payment details
|
||||
const encryptedDetails = encryptPaymentDetails(paymentDetails);
|
||||
@@ -271,12 +322,13 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
|
||||
const { data: offer, error: offerError } = await supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.insert({
|
||||
seller_id: (await supabase.auth.getUser()).data.user?.id,
|
||||
seller_wallet: account.address,
|
||||
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,
|
||||
@@ -284,41 +336,39 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
|
||||
time_limit_minutes: timeLimitMinutes,
|
||||
status: 'open',
|
||||
remaining_amount: amountCrypto,
|
||||
escrow_tx_hash: txHash,
|
||||
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. Record escrow in platform_escrow table
|
||||
await supabase
|
||||
.from('p2p_platform_escrow')
|
||||
.insert({
|
||||
offer_id: offer.id,
|
||||
seller_id: offer.seller_id,
|
||||
seller_wallet: account.address,
|
||||
token,
|
||||
amount: amountCrypto,
|
||||
blockchain_tx_lock: txHash,
|
||||
status: 'locked'
|
||||
});
|
||||
// 4. Update the lock with offer reference
|
||||
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
|
||||
fiat_amount: fiatAmount,
|
||||
escrow_type: 'internal_ledger'
|
||||
});
|
||||
|
||||
toast.success(`Offer created! Selling ${amountCrypto} ${token} for ${fiatAmount} ${fiatCurrency}`);
|
||||
|
||||
|
||||
return offer.id;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Create offer error:', error);
|
||||
toast.error(error.message || 'Failed to create offer');
|
||||
const message = error instanceof Error ? error.message : 'Failed to create offer';
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -460,15 +510,21 @@ export async function markPaymentSent(
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* Seller confirms payment received and releases crypto
|
||||
* 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(
|
||||
api: ApiPromise,
|
||||
account: InjectedAccountWithMeta,
|
||||
tradeId: string
|
||||
): Promise<void> {
|
||||
export async function confirmPaymentReceived(tradeId: string): Promise<void> {
|
||||
try {
|
||||
// 1. Get trade details
|
||||
// 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('*')
|
||||
@@ -477,62 +533,76 @@ export async function confirmPaymentReceived(
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// 2. Release crypto from escrow to buyer (blockchain tx)
|
||||
toast.info('Releasing crypto to buyer...');
|
||||
|
||||
const amount = BigInt(trade.crypto_amount * 1e12);
|
||||
// 3. Get offer to get token type
|
||||
const { data: offer } = await supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.select('token')
|
||||
.eq('id', trade.offer_id)
|
||||
.single();
|
||||
|
||||
let releaseTxHash: string;
|
||||
if (offer?.token === 'HEZ') {
|
||||
const tx = api.tx.balances.transfer(trade.buyer_wallet, amount);
|
||||
releaseTxHash = await signAndSendWithPlatformKey(api, tx);
|
||||
} else {
|
||||
const assetId = ASSET_IDS[offer?.token as CryptoToken];
|
||||
const tx = api.tx.assets.transfer(assetId, trade.buyer_wallet, amount);
|
||||
releaseTxHash = await signAndSendWithPlatformKey(api, tx);
|
||||
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');
|
||||
}
|
||||
|
||||
// 3. Update trade status
|
||||
// 5. Update trade status
|
||||
const { error: updateError } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.update({
|
||||
seller_confirmed_at: new Date().toISOString(),
|
||||
escrow_release_tx_hash: releaseTxHash,
|
||||
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;
|
||||
|
||||
// 4. Update escrow balance
|
||||
await supabase.rpc('decrement_escrow_balance', {
|
||||
p_token: offer?.token,
|
||||
p_amount: trade.crypto_amount
|
||||
});
|
||||
|
||||
// 5. Update reputations
|
||||
// 6. Update reputations
|
||||
await updateReputations(trade.seller_id, trade.buyer_id, tradeId);
|
||||
|
||||
// 6. Audit log
|
||||
// 7. Audit log
|
||||
await logAction('trade', tradeId, 'confirm_payment', {
|
||||
release_tx_hash: releaseTxHash
|
||||
released_amount: trade.crypto_amount,
|
||||
token: offer.token,
|
||||
escrow_type: 'internal_ledger'
|
||||
});
|
||||
|
||||
toast.success('Payment confirmed! Crypto released to buyer.');
|
||||
} catch (error: any) {
|
||||
toast.success('Payment confirmed! Crypto released to buyer\'s balance.');
|
||||
} catch (error: unknown) {
|
||||
console.error('Confirm payment error:', error);
|
||||
toast.error(error.message || 'Failed to confirm payment');
|
||||
const message = error instanceof Error ? error.message : 'Failed to confirm payment';
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -579,11 +649,9 @@ async function signAndSendTx(
|
||||
});
|
||||
}
|
||||
|
||||
async function signAndSendWithPlatformKey(api: ApiPromise, tx: any): Promise<string> {
|
||||
// TODO: Implement multisig or server-side signing
|
||||
// For now, this is a placeholder
|
||||
throw new Error('Platform signing not implemented - requires multisig setup');
|
||||
}
|
||||
// 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', {
|
||||
@@ -808,3 +876,192 @@ export async function updateUserReputation(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a deposit transaction and credit internal balance
|
||||
* NOTE: In production, this should be called by backend service after verifying on-chain
|
||||
*/
|
||||
export async function verifyDeposit(
|
||||
txHash: string,
|
||||
token: CryptoToken,
|
||||
amount: number
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
const userId = userData.user?.id;
|
||||
if (!userId) throw new Error('Not authenticated');
|
||||
|
||||
toast.info('Verifying deposit...');
|
||||
|
||||
// Call the database function
|
||||
const { data, error } = await supabase.rpc('process_deposit', {
|
||||
p_user_id: userId,
|
||||
p_token: token,
|
||||
p_amount: amount,
|
||||
p_tx_hash: txHash
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Parse result
|
||||
const result = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Deposit verification failed');
|
||||
}
|
||||
|
||||
toast.success(`Deposit verified! ${amount} ${token} added to your balance.`);
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
console.error('Verify deposit error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Deposit verification failed';
|
||||
toast.error(message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user