mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 06:47:55 +00:00
feat(p2p): add atomic escrow system with race condition protection
- Add p2p_platform_escrow table for tracking locked funds - Implement accept_p2p_offer() with FOR UPDATE lock to prevent race conditions - Add complete_p2p_trade() and cancel_p2p_trade() atomic functions - Configure platform escrow wallet: 5DFwqK698vL4gXHEcanaewnAqhxJ2rjhAogpSTHw3iwGDwd3 - Update AdList to show user's own offers with Your Ad badge - Remove unused getActiveOffers import (ESLint fix)
This commit is contained in:
+76
-88
@@ -124,7 +124,7 @@ export interface AcceptOfferParams {
|
||||
// CONSTANTS
|
||||
// =====================================================
|
||||
|
||||
const PLATFORM_ESCROW_ADDRESS = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY';
|
||||
const PLATFORM_ESCROW_ADDRESS = '5DFwqK698vL4gXHEcanaewnAqhxJ2rjhAogpSTHw3iwGDwd3';
|
||||
|
||||
const ASSET_IDS = {
|
||||
HEZ: null, // Native token
|
||||
@@ -292,11 +292,18 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
|
||||
|
||||
if (offerError) throw offerError;
|
||||
|
||||
// 4. Update escrow balance
|
||||
await supabase.rpc('increment_escrow_balance', {
|
||||
p_token: token,
|
||||
p_amount: amountCrypto
|
||||
});
|
||||
// 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'
|
||||
});
|
||||
|
||||
// 5. Audit log
|
||||
await logAction('offer', offer.id, 'create_offer', {
|
||||
@@ -324,103 +331,76 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
|
||||
* Accept a P2P fiat offer (buyer)
|
||||
*/
|
||||
export async function acceptFiatOffer(params: AcceptOfferParams): Promise<string> {
|
||||
const { api, account, offerId, amount } = params;
|
||||
const { account, offerId, amount } = params;
|
||||
|
||||
try {
|
||||
// 1. Get offer details
|
||||
// 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('*')
|
||||
.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');
|
||||
if (offer.status !== 'open') throw new Error('Offer is not available');
|
||||
|
||||
// 2. Determine trade amount
|
||||
const tradeAmount = amount || offer.remaining_amount;
|
||||
|
||||
if (offer.min_order_amount && tradeAmount < offer.min_order_amount) {
|
||||
throw new Error(`Minimum order: ${offer.min_order_amount} ${offer.token}`);
|
||||
}
|
||||
|
||||
if (offer.max_order_amount && tradeAmount > offer.max_order_amount) {
|
||||
throw new Error(`Maximum order: ${offer.max_order_amount} ${offer.token}`);
|
||||
}
|
||||
|
||||
if (tradeAmount > offer.remaining_amount) {
|
||||
throw new Error('Insufficient 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();
|
||||
|
||||
const tradeFiatAmount = (tradeAmount / offer.amount_crypto) * offer.fiat_amount;
|
||||
|
||||
// 3. Check buyer reputation
|
||||
const { data: user } = await supabase.auth.getUser();
|
||||
if (!user.user) throw new Error('Not authenticated');
|
||||
|
||||
const { data: reputation } = await supabase
|
||||
.from('p2p_reputation')
|
||||
.select('*')
|
||||
.eq('user_id', user.user.id)
|
||||
.single();
|
||||
|
||||
if (reputation) {
|
||||
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`);
|
||||
}
|
||||
} else if (offer.min_buyer_completed_trades > 0 || offer.min_buyer_reputation > 0) {
|
||||
throw new Error('Seller requires experienced buyers');
|
||||
}
|
||||
|
||||
// 4. Create trade
|
||||
const paymentDeadline = new Date(Date.now() + offer.time_limit_minutes * 60 * 1000);
|
||||
// 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: account.address,
|
||||
p_amount: tradeAmount
|
||||
});
|
||||
|
||||
const { data: trade, error: tradeError } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.insert({
|
||||
offer_id: offerId,
|
||||
seller_id: offer.seller_id,
|
||||
buyer_id: user.user.id,
|
||||
buyer_wallet: account.address,
|
||||
crypto_amount: tradeAmount,
|
||||
fiat_amount: tradeFiatAmount,
|
||||
price_per_unit: offer.price_per_unit,
|
||||
escrow_locked_amount: tradeAmount,
|
||||
escrow_locked_at: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
payment_deadline: paymentDeadline.toISOString()
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (rpcError) throw rpcError;
|
||||
|
||||
if (tradeError) throw tradeError;
|
||||
// Parse result (may be string or object depending on Supabase version)
|
||||
const response = typeof result === 'string' ? JSON.parse(result) : result;
|
||||
|
||||
// 5. Update offer remaining amount
|
||||
await supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.update({
|
||||
remaining_amount: offer.remaining_amount - tradeAmount,
|
||||
status: offer.remaining_amount - tradeAmount === 0 ? 'locked' : 'open'
|
||||
})
|
||||
.eq('id', offerId);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to accept offer');
|
||||
}
|
||||
|
||||
// 6. Audit log
|
||||
await logAction('trade', trade.id, 'accept_offer', {
|
||||
// 5. Audit log
|
||||
await logAction('trade', response.trade_id, 'accept_offer', {
|
||||
offer_id: offerId,
|
||||
crypto_amount: tradeAmount,
|
||||
fiat_amount: tradeFiatAmount
|
||||
crypto_amount: response.crypto_amount,
|
||||
fiat_amount: response.fiat_amount
|
||||
});
|
||||
|
||||
toast.success('Trade started! Send payment within time limit.');
|
||||
|
||||
return trade.id;
|
||||
} catch (error: any) {
|
||||
|
||||
return response.trade_id;
|
||||
} catch (error: unknown) {
|
||||
console.error('Accept offer error:', error);
|
||||
toast.error(error.message || 'Failed to accept offer');
|
||||
const message = error instanceof Error ? error.message : 'Failed to accept offer';
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -566,28 +546,36 @@ async function signAndSendTx(
|
||||
account: InjectedAccountWithMeta,
|
||||
tx: any
|
||||
): Promise<string> {
|
||||
// Get signer from Polkadot.js extension
|
||||
const { web3FromSource } = await import('@polkadot/extension-dapp');
|
||||
const injector = await web3FromSource(account.meta.source);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let unsub: () => void;
|
||||
|
||||
tx.signAndSend(account.address, ({ 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()));
|
||||
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 (unsub) unsub();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
resolve(txHash.toString());
|
||||
if (unsub) unsub();
|
||||
if (status.isInBlock || status.isFinalized) {
|
||||
resolve(txHash.toString());
|
||||
if (unsub) unsub();
|
||||
}
|
||||
}
|
||||
}).then((unsubscribe: () => void) => {
|
||||
).then((unsubscribe: () => void) => {
|
||||
unsub = unsubscribe;
|
||||
});
|
||||
}).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,19 +6,23 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Loader2, Shield, Zap } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { TradeModal } from './TradeModal';
|
||||
import { getActiveOffers, getUserReputation, type P2PFiatOffer, type P2PReputation } from '@shared/lib/p2p-fiat';
|
||||
import { MerchantTierBadge } from './MerchantTierBadge';
|
||||
import { getUserReputation, type P2PFiatOffer, type P2PReputation } from '@shared/lib/p2p-fiat';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import type { P2PFilters } from './OrderFilters';
|
||||
|
||||
interface AdListProps {
|
||||
type: 'buy' | 'sell' | 'my-ads';
|
||||
filters?: P2PFilters;
|
||||
}
|
||||
|
||||
interface OfferWithReputation extends P2PFiatOffer {
|
||||
seller_reputation?: P2PReputation;
|
||||
payment_method_name?: string;
|
||||
merchant_tier?: 'lite' | 'super' | 'diamond';
|
||||
}
|
||||
|
||||
export function AdList({ type }: AdListProps) {
|
||||
export function AdList({ type, filters }: AdListProps) {
|
||||
const { user } = useAuth();
|
||||
const [offers, setOffers] = useState<OfferWithReputation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -26,8 +30,21 @@ export function AdList({ type }: AdListProps) {
|
||||
|
||||
useEffect(() => {
|
||||
fetchOffers();
|
||||
|
||||
// Refresh data when user returns to the tab (visibility change)
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
fetchOffers();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type, user]);
|
||||
}, [type, user, filters]);
|
||||
|
||||
const fetchOffers = async () => {
|
||||
setLoading(true);
|
||||
@@ -35,16 +52,37 @@ export function AdList({ type }: AdListProps) {
|
||||
let offersData: P2PFiatOffer[] = [];
|
||||
|
||||
if (type === 'buy') {
|
||||
// Buy = looking for sell offers
|
||||
offersData = await getActiveOffers();
|
||||
// Buy tab = show SELL offers (user wants to buy from sellers)
|
||||
// Include ALL offers (user can see their own but can't trade with them)
|
||||
const { data } = await supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.select('*')
|
||||
.eq('ad_type', 'sell')
|
||||
.eq('status', 'open')
|
||||
.gt('remaining_amount', 0)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
offersData = data || [];
|
||||
} else if (type === 'sell') {
|
||||
// Sell tab = show BUY offers (user wants to sell to buyers)
|
||||
// Include ALL offers (user can see their own but can't trade with them)
|
||||
const { data } = await supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.select('*')
|
||||
.eq('ad_type', 'buy')
|
||||
.eq('status', 'open')
|
||||
.gt('remaining_amount', 0)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
offersData = data || [];
|
||||
} else if (type === 'my-ads' && user) {
|
||||
// My offers
|
||||
// My offers - show all of user's offers
|
||||
const { data } = await supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.select('*')
|
||||
.eq('seller_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
|
||||
offersData = data || [];
|
||||
}
|
||||
|
||||
@@ -112,6 +150,9 @@ export function AdList({ type }: AdListProps) {
|
||||
<p className="font-semibold text-white">
|
||||
{offer.seller_wallet.slice(0, 6)}...{offer.seller_wallet.slice(-4)}
|
||||
</p>
|
||||
{offer.merchant_tier && (
|
||||
<MerchantTierBadge tier={offer.merchant_tier} size="sm" />
|
||||
)}
|
||||
{offer.seller_reputation?.verified_merchant && (
|
||||
<Shield className="w-4 h-4 text-blue-400" title="Verified Merchant" />
|
||||
)}
|
||||
@@ -161,11 +202,17 @@ export function AdList({ type }: AdListProps) {
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{offer.seller_id === user?.id && type !== 'my-ads' && (
|
||||
<Badge variant="outline" className="text-xs bg-blue-500/10 text-blue-400 border-blue-500/30">
|
||||
Your Ad
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setSelectedOffer(offer)}
|
||||
disabled={type === 'my-ads'}
|
||||
disabled={type === 'my-ads' || offer.seller_id === user?.id}
|
||||
className="w-full md:w-auto"
|
||||
title={offer.seller_id === user?.id ? "You can't trade with your own ad" : ''}
|
||||
>
|
||||
{type === 'buy' ? 'Buy' : 'Sell'} {offer.token}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
-- =====================================================
|
||||
-- P2P ATOMIC ESCROW & PLATFORM WALLET SYSTEM
|
||||
-- Migration: 013_p2p_atomic_escrow.sql
|
||||
-- =====================================================
|
||||
|
||||
-- 1. Platform escrow wallet tracking table
|
||||
CREATE TABLE IF NOT EXISTS p2p_platform_escrow (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
offer_id UUID NOT NULL REFERENCES p2p_fiat_offers(id) ON DELETE CASCADE,
|
||||
seller_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
seller_wallet TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
amount DECIMAL(20, 12) NOT NULL,
|
||||
locked_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
released_at TIMESTAMPTZ,
|
||||
released_to TEXT, -- wallet address
|
||||
release_reason TEXT, -- 'trade_complete', 'trade_cancelled', 'trade_expired', 'refund'
|
||||
blockchain_tx_lock TEXT, -- tx hash for lock transfer
|
||||
blockchain_tx_release TEXT, -- tx hash for release transfer
|
||||
status TEXT NOT NULL DEFAULT 'locked' CHECK (status IN ('locked', 'released', 'pending_release')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for quick lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_escrow_offer ON p2p_platform_escrow(offer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_escrow_seller ON p2p_platform_escrow(seller_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_escrow_status ON p2p_platform_escrow(status);
|
||||
|
||||
-- 2. Atomic function to accept offer (prevents race condition)
|
||||
CREATE OR REPLACE FUNCTION accept_p2p_offer(
|
||||
p_offer_id UUID,
|
||||
p_buyer_id UUID,
|
||||
p_buyer_wallet TEXT,
|
||||
p_amount DECIMAL(20, 12)
|
||||
) RETURNS JSON AS $$
|
||||
DECLARE
|
||||
v_offer RECORD;
|
||||
v_trade_id UUID;
|
||||
v_payment_deadline TIMESTAMPTZ;
|
||||
v_fiat_amount DECIMAL(20, 2);
|
||||
BEGIN
|
||||
-- Lock the offer row for update (prevents concurrent modifications)
|
||||
SELECT * INTO v_offer
|
||||
FROM p2p_fiat_offers
|
||||
WHERE id = p_offer_id
|
||||
FOR UPDATE;
|
||||
|
||||
-- Validation checks
|
||||
IF v_offer IS NULL THEN
|
||||
RETURN json_build_object('success', false, 'error', 'Offer not found');
|
||||
END IF;
|
||||
|
||||
IF v_offer.status != 'open' THEN
|
||||
RETURN json_build_object('success', false, 'error', 'Offer is not available');
|
||||
END IF;
|
||||
|
||||
IF v_offer.seller_id = p_buyer_id THEN
|
||||
RETURN json_build_object('success', false, 'error', 'Cannot buy from your own offer');
|
||||
END IF;
|
||||
|
||||
IF p_amount > v_offer.remaining_amount THEN
|
||||
RETURN json_build_object('success', false, 'error', 'Insufficient remaining amount. Available: ' || v_offer.remaining_amount);
|
||||
END IF;
|
||||
|
||||
IF v_offer.min_order_amount IS NOT NULL AND p_amount < v_offer.min_order_amount THEN
|
||||
RETURN json_build_object('success', false, 'error', 'Minimum order: ' || v_offer.min_order_amount || ' ' || v_offer.token);
|
||||
END IF;
|
||||
|
||||
IF v_offer.max_order_amount IS NOT NULL AND p_amount > v_offer.max_order_amount THEN
|
||||
RETURN json_build_object('success', false, 'error', 'Maximum order: ' || v_offer.max_order_amount || ' ' || v_offer.token);
|
||||
END IF;
|
||||
|
||||
-- Calculate fiat amount
|
||||
v_fiat_amount := (p_amount / v_offer.amount_crypto) * v_offer.fiat_amount;
|
||||
v_payment_deadline := NOW() + (v_offer.time_limit_minutes || ' minutes')::INTERVAL;
|
||||
|
||||
-- Create trade
|
||||
INSERT INTO p2p_fiat_trades (
|
||||
offer_id,
|
||||
seller_id,
|
||||
buyer_id,
|
||||
buyer_wallet,
|
||||
crypto_amount,
|
||||
fiat_amount,
|
||||
price_per_unit,
|
||||
escrow_locked_amount,
|
||||
escrow_locked_at,
|
||||
status,
|
||||
payment_deadline
|
||||
) VALUES (
|
||||
p_offer_id,
|
||||
v_offer.seller_id,
|
||||
p_buyer_id,
|
||||
p_buyer_wallet,
|
||||
p_amount,
|
||||
v_fiat_amount,
|
||||
v_offer.price_per_unit,
|
||||
p_amount,
|
||||
NOW(),
|
||||
'pending',
|
||||
v_payment_deadline
|
||||
) RETURNING id INTO v_trade_id;
|
||||
|
||||
-- Atomically update remaining amount
|
||||
UPDATE p2p_fiat_offers
|
||||
SET
|
||||
remaining_amount = remaining_amount - p_amount,
|
||||
status = CASE
|
||||
WHEN remaining_amount - p_amount = 0 THEN 'locked'
|
||||
ELSE 'open'
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE id = p_offer_id;
|
||||
|
||||
RETURN json_build_object(
|
||||
'success', true,
|
||||
'trade_id', v_trade_id,
|
||||
'crypto_amount', p_amount,
|
||||
'fiat_amount', v_fiat_amount,
|
||||
'payment_deadline', v_payment_deadline
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 3. Function to release escrow on trade completion
|
||||
CREATE OR REPLACE FUNCTION complete_p2p_trade(
|
||||
p_trade_id UUID,
|
||||
p_seller_id UUID
|
||||
) RETURNS JSON AS $$
|
||||
DECLARE
|
||||
v_trade RECORD;
|
||||
BEGIN
|
||||
-- Lock trade row
|
||||
SELECT * INTO v_trade
|
||||
FROM p2p_fiat_trades
|
||||
WHERE id = p_trade_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_trade IS NULL THEN
|
||||
RETURN json_build_object('success', false, 'error', 'Trade not found');
|
||||
END IF;
|
||||
|
||||
IF v_trade.seller_id != p_seller_id THEN
|
||||
RETURN json_build_object('success', false, 'error', 'Only seller can confirm receipt');
|
||||
END IF;
|
||||
|
||||
IF v_trade.status != 'payment_sent' THEN
|
||||
RETURN json_build_object('success', false, 'error', 'Payment not marked as sent yet');
|
||||
END IF;
|
||||
|
||||
-- Update trade status
|
||||
UPDATE p2p_fiat_trades
|
||||
SET
|
||||
status = 'completed',
|
||||
completed_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = p_trade_id;
|
||||
|
||||
-- Update escrow status (will be released by backend)
|
||||
UPDATE p2p_platform_escrow
|
||||
SET
|
||||
status = 'pending_release',
|
||||
released_to = v_trade.buyer_wallet,
|
||||
release_reason = 'trade_complete',
|
||||
updated_at = NOW()
|
||||
WHERE offer_id = v_trade.offer_id
|
||||
AND status = 'locked';
|
||||
|
||||
RETURN json_build_object(
|
||||
'success', true,
|
||||
'buyer_wallet', v_trade.buyer_wallet,
|
||||
'amount', v_trade.crypto_amount,
|
||||
'token', (SELECT token FROM p2p_fiat_offers WHERE id = v_trade.offer_id)
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 4. Function to cancel/expire trade (return to seller)
|
||||
CREATE OR REPLACE FUNCTION cancel_p2p_trade(
|
||||
p_trade_id UUID,
|
||||
p_user_id UUID,
|
||||
p_reason TEXT DEFAULT 'cancelled'
|
||||
) RETURNS JSON AS $$
|
||||
DECLARE
|
||||
v_trade RECORD;
|
||||
v_offer RECORD;
|
||||
BEGIN
|
||||
-- Lock trade row
|
||||
SELECT * INTO v_trade
|
||||
FROM p2p_fiat_trades
|
||||
WHERE id = p_trade_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_trade IS NULL THEN
|
||||
RETURN json_build_object('success', false, 'error', 'Trade not found');
|
||||
END IF;
|
||||
|
||||
-- Only buyer can cancel before payment, or system for expiry
|
||||
IF v_trade.status = 'pending' AND v_trade.buyer_id != p_user_id THEN
|
||||
RETURN json_build_object('success', false, 'error', 'Only buyer can cancel pending trade');
|
||||
END IF;
|
||||
|
||||
IF v_trade.status NOT IN ('pending', 'payment_sent') THEN
|
||||
RETURN json_build_object('success', false, 'error', 'Trade cannot be cancelled in current status');
|
||||
END IF;
|
||||
|
||||
-- Get offer details
|
||||
SELECT * INTO v_offer FROM p2p_fiat_offers WHERE id = v_trade.offer_id;
|
||||
|
||||
-- Update trade status
|
||||
UPDATE p2p_fiat_trades
|
||||
SET
|
||||
status = 'cancelled',
|
||||
cancelled_at = NOW(),
|
||||
cancelled_by = p_user_id,
|
||||
updated_at = NOW()
|
||||
WHERE id = p_trade_id;
|
||||
|
||||
-- Return amount to offer
|
||||
UPDATE p2p_fiat_offers
|
||||
SET
|
||||
remaining_amount = remaining_amount + v_trade.crypto_amount,
|
||||
status = 'open',
|
||||
updated_at = NOW()
|
||||
WHERE id = v_trade.offer_id;
|
||||
|
||||
RETURN json_build_object(
|
||||
'success', true,
|
||||
'refunded_amount', v_trade.crypto_amount,
|
||||
'reason', p_reason
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 5. RLS policies for platform escrow
|
||||
ALTER TABLE p2p_platform_escrow ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Sellers can view their own escrow
|
||||
CREATE POLICY "Sellers can view their escrow"
|
||||
ON p2p_platform_escrow FOR SELECT
|
||||
USING (seller_id = auth.uid());
|
||||
|
||||
-- Admins can view all escrow
|
||||
CREATE POLICY "Admins can view all escrow"
|
||||
ON p2p_platform_escrow FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE profiles.id = auth.uid()
|
||||
AND profiles.role IN ('admin', 'super_admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- Only system can insert/update (via service role)
|
||||
-- No INSERT/UPDATE policies for regular users
|
||||
|
||||
-- 6. Add platform_wallet_address to config
|
||||
CREATE TABLE IF NOT EXISTS p2p_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
description TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Insert platform wallet config (will be updated with real address)
|
||||
INSERT INTO p2p_config (key, value, description)
|
||||
VALUES (
|
||||
'platform_escrow_wallet',
|
||||
'5DFwqK698vL4gXHEcanaewnAqhxJ2rjhAogpSTHw3iwGDwd3',
|
||||
'Platform wallet address for P2P escrow'
|
||||
)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE p2p_platform_escrow IS 'Tracks all escrow deposits for P2P trades';
|
||||
COMMENT ON TABLE p2p_config IS 'P2P system configuration';
|
||||
Reference in New Issue
Block a user