mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-06-18 20:51:01 +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:
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* P2P Withdrawal Processing Edge Function
|
||||
*
|
||||
* This function processes pending withdrawal requests from the P2P internal balance system.
|
||||
* It should be called by a cron job or triggered manually by admin.
|
||||
*
|
||||
* Security:
|
||||
* - Uses service role key for database operations
|
||||
* - Platform wallet private key stored in environment variables
|
||||
* - All transactions are logged for auditing
|
||||
*
|
||||
* Flow:
|
||||
* 1. Fetch pending withdrawal requests
|
||||
* 2. For each request:
|
||||
* a. Lock the request (status = 'processing')
|
||||
* b. Execute blockchain transfer
|
||||
* c. Update request with tx hash
|
||||
* d. Deduct from user's internal balance
|
||||
* e. Mark as completed
|
||||
*/
|
||||
|
||||
// @ts-ignore - Deno imports
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
// @ts-ignore - Deno imports
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
// @ts-ignore - Polkadot imports for Deno
|
||||
import { ApiPromise, WsProvider, Keyring } from "https://esm.sh/@polkadot/api@11.0.2";
|
||||
// @ts-ignore - Deno imports
|
||||
import { cryptoWaitReady } from "https://esm.sh/@polkadot/util-crypto@12.6.2";
|
||||
|
||||
// Configuration
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const PLATFORM_WALLET_SEED = Deno.env.get("PLATFORM_WALLET_SEED")!;
|
||||
const RPC_ENDPOINT = Deno.env.get("RPC_ENDPOINT") || "wss://rpc.pezkuwichain.io:9944";
|
||||
|
||||
// Asset IDs
|
||||
const ASSET_IDS: Record<string, number | null> = {
|
||||
HEZ: null, // Native token
|
||||
PEZ: 1,
|
||||
};
|
||||
|
||||
// Decimals
|
||||
const DECIMALS = 12;
|
||||
|
||||
interface WithdrawRequest {
|
||||
id: string;
|
||||
user_id: string;
|
||||
token: string;
|
||||
amount: number;
|
||||
wallet_address: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single withdrawal request
|
||||
*/
|
||||
async function processWithdrawal(
|
||||
api: ApiPromise,
|
||||
keyring: Keyring,
|
||||
supabase: ReturnType<typeof createClient>,
|
||||
request: WithdrawRequest
|
||||
): Promise<{ success: boolean; txHash?: string; error?: string }> {
|
||||
const { id, token, amount, wallet_address } = request;
|
||||
|
||||
try {
|
||||
console.log(`Processing withdrawal ${id}: ${amount} ${token} to ${wallet_address}`);
|
||||
|
||||
// 1. Get platform wallet from keyring
|
||||
const platformWallet = keyring.addFromUri(PLATFORM_WALLET_SEED);
|
||||
|
||||
// 2. Calculate amount in planck (smallest unit)
|
||||
const amountPlanck = BigInt(Math.floor(amount * Math.pow(10, DECIMALS)));
|
||||
|
||||
// 3. Build transaction based on token type
|
||||
let tx;
|
||||
if (token === "HEZ" || ASSET_IDS[token] === null) {
|
||||
// Native token transfer
|
||||
tx = api.tx.balances.transferKeepAlive(wallet_address, amountPlanck);
|
||||
} else {
|
||||
// Asset transfer
|
||||
const assetId = ASSET_IDS[token];
|
||||
if (assetId === undefined) {
|
||||
throw new Error(`Unknown token: ${token}`);
|
||||
}
|
||||
tx = api.tx.assets.transfer(assetId, wallet_address, amountPlanck);
|
||||
}
|
||||
|
||||
// 4. Sign and send transaction
|
||||
const txHash = await new Promise<string>((resolve, reject) => {
|
||||
let unsubscribe: () => void;
|
||||
|
||||
tx.signAndSend(platformWallet, { nonce: -1 }, ({ status, dispatchError }) => {
|
||||
if (dispatchError) {
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(" ")}`));
|
||||
} else {
|
||||
reject(new Error(dispatchError.toString()));
|
||||
}
|
||||
if (unsubscribe) unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.isInBlock) {
|
||||
console.log(`Transaction in block: ${status.asInBlock.toString()}`);
|
||||
}
|
||||
|
||||
if (status.isFinalized) {
|
||||
const hash = tx.hash.toHex();
|
||||
console.log(`Transaction finalized: ${hash}`);
|
||||
resolve(hash);
|
||||
if (unsubscribe) unsubscribe();
|
||||
}
|
||||
})
|
||||
.then((unsub) => {
|
||||
unsubscribe = unsub;
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
|
||||
// 5. Update request with tx hash and complete status
|
||||
const { error: updateError } = await supabase
|
||||
.from("p2p_deposit_withdraw_requests")
|
||||
.update({
|
||||
status: "completed",
|
||||
blockchain_tx_hash: txHash,
|
||||
processed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", id);
|
||||
|
||||
if (updateError) {
|
||||
console.error(`Failed to update request ${id}:`, updateError);
|
||||
// Transaction was successful, but we couldn't update the database
|
||||
// This should be handled manually
|
||||
return { success: true, txHash, error: "Database update failed after successful tx" };
|
||||
}
|
||||
|
||||
// 6. Log the withdrawal in balance transactions
|
||||
const { error: logError } = await supabase.from("p2p_balance_transactions").insert({
|
||||
user_id: request.user_id,
|
||||
token,
|
||||
transaction_type: "withdraw",
|
||||
amount: -amount,
|
||||
balance_before: 0, // Will be calculated by trigger
|
||||
balance_after: 0, // Will be calculated by trigger
|
||||
reference_type: "withdrawal",
|
||||
reference_id: id,
|
||||
description: `Withdrawal to ${wallet_address.slice(0, 8)}...${wallet_address.slice(-6)}`,
|
||||
});
|
||||
|
||||
if (logError) {
|
||||
console.error(`Failed to log transaction for ${id}:`, logError);
|
||||
}
|
||||
|
||||
console.log(`Successfully processed withdrawal ${id}: ${txHash}`);
|
||||
return { success: true, txHash };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(`Failed to process withdrawal ${id}:`, errorMessage);
|
||||
|
||||
// Update request with error
|
||||
await supabase
|
||||
.from("p2p_deposit_withdraw_requests")
|
||||
.update({
|
||||
status: "failed",
|
||||
error_message: errorMessage,
|
||||
processed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", id);
|
||||
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main handler
|
||||
*/
|
||||
serve(async (req: Request) => {
|
||||
// CORS headers
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify authorization (should be called with service role key or admin JWT)
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unauthorized" }),
|
||||
{ status: 401, headers }
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Supabase client with service role
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
||||
|
||||
// Parse request body for optional filters
|
||||
let requestBody: { limit?: number; requestId?: string } = {};
|
||||
try {
|
||||
requestBody = await req.json();
|
||||
} catch {
|
||||
// No body provided, use defaults
|
||||
}
|
||||
|
||||
const { limit = 10, requestId } = requestBody;
|
||||
|
||||
// 1. Fetch pending withdrawal requests
|
||||
let query = supabase
|
||||
.from("p2p_deposit_withdraw_requests")
|
||||
.select("*")
|
||||
.eq("request_type", "withdraw")
|
||||
.eq("status", "pending")
|
||||
.order("created_at", { ascending: true })
|
||||
.limit(limit);
|
||||
|
||||
// If specific request ID provided, fetch only that one
|
||||
if (requestId) {
|
||||
query = supabase
|
||||
.from("p2p_deposit_withdraw_requests")
|
||||
.select("*")
|
||||
.eq("id", requestId)
|
||||
.eq("request_type", "withdraw")
|
||||
.in("status", ["pending", "failed"]);
|
||||
}
|
||||
|
||||
const { data: pendingRequests, error: fetchError } = await query;
|
||||
|
||||
if (fetchError) {
|
||||
throw new Error(`Failed to fetch pending requests: ${fetchError.message}`);
|
||||
}
|
||||
|
||||
if (!pendingRequests || pendingRequests.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "No pending withdrawal requests", processed: 0 }),
|
||||
{ headers }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Found ${pendingRequests.length} pending withdrawal requests`);
|
||||
|
||||
// 2. Lock the requests (set status to processing)
|
||||
const requestIds = pendingRequests.map((r: WithdrawRequest) => r.id);
|
||||
const { error: lockError } = await supabase
|
||||
.from("p2p_deposit_withdraw_requests")
|
||||
.update({ status: "processing" })
|
||||
.in("id", requestIds);
|
||||
|
||||
if (lockError) {
|
||||
throw new Error(`Failed to lock requests: ${lockError.message}`);
|
||||
}
|
||||
|
||||
// 3. Initialize Polkadot API
|
||||
console.log(`Connecting to blockchain: ${RPC_ENDPOINT}`);
|
||||
await cryptoWaitReady();
|
||||
|
||||
const provider = new WsProvider(RPC_ENDPOINT);
|
||||
const api = await ApiPromise.create({ provider });
|
||||
await api.isReady;
|
||||
|
||||
const keyring = new Keyring({ type: "sr25519" });
|
||||
|
||||
// 4. Process each withdrawal
|
||||
const results = [];
|
||||
for (const request of pendingRequests as WithdrawRequest[]) {
|
||||
const result = await processWithdrawal(api, keyring, supabase, request);
|
||||
results.push({
|
||||
id: request.id,
|
||||
...result,
|
||||
});
|
||||
|
||||
// Small delay between transactions to avoid nonce issues
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// 5. Cleanup
|
||||
await api.disconnect();
|
||||
|
||||
// 6. Return results
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const failCount = results.filter((r) => !r.success).length;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: `Processed ${results.length} withdrawal requests`,
|
||||
processed: results.length,
|
||||
success: successCount,
|
||||
failed: failCount,
|
||||
results,
|
||||
}),
|
||||
{ headers }
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("Error processing withdrawals:", errorMessage);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: errorMessage }),
|
||||
{ status: 500, headers }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* P2P Deposit Verification Edge Function
|
||||
*
|
||||
* This function verifies deposit transactions on the blockchain and credits
|
||||
* the user's internal P2P balance.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User sends tokens to platform wallet (from frontend)
|
||||
* 2. User calls this function with tx hash
|
||||
* 3. Function verifies transaction on-chain:
|
||||
* - Confirms tx is finalized
|
||||
* - Confirms correct recipient (platform wallet)
|
||||
* - Confirms amount matches claimed amount
|
||||
* - Confirms token type
|
||||
* 4. Credits user's internal balance
|
||||
* 5. Creates audit record
|
||||
*/
|
||||
|
||||
// @ts-ignore - Deno imports
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
// @ts-ignore - Deno imports
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
// @ts-ignore - Polkadot imports for Deno
|
||||
import { ApiPromise, WsProvider } from "https://esm.sh/@polkadot/api@11.0.2";
|
||||
|
||||
// Configuration
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const PLATFORM_WALLET_ADDRESS = Deno.env.get("PLATFORM_WALLET_ADDRESS") || "5DFwqK698vL4gXHEcanaewnAqhxJ2rjhAogpSTHw3iwGDwd3";
|
||||
const RPC_ENDPOINT = Deno.env.get("RPC_ENDPOINT") || "wss://rpc.pezkuwichain.io:9944";
|
||||
|
||||
// Decimals
|
||||
const DECIMALS = 12;
|
||||
|
||||
// Tolerance for amount verification (0.1%)
|
||||
const AMOUNT_TOLERANCE = 0.001;
|
||||
|
||||
interface VerifyDepositRequest {
|
||||
txHash: string;
|
||||
token: "HEZ" | "PEZ";
|
||||
expectedAmount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse block events to find transfer details
|
||||
*/
|
||||
function findTransferInEvents(
|
||||
api: ApiPromise,
|
||||
events: Array<{ event: { section: string; method: string; data: unknown[] } }>,
|
||||
token: string
|
||||
): { from: string; to: string; amount: bigint } | null {
|
||||
for (const { event } of events) {
|
||||
// Native HEZ transfer
|
||||
if (token === "HEZ" && event.section === "balances" && event.method === "Transfer") {
|
||||
const [from, to, amount] = event.data as [
|
||||
{ toString: () => string },
|
||||
{ toString: () => string },
|
||||
{ toBigInt: () => bigint }
|
||||
];
|
||||
return {
|
||||
from: from.toString(),
|
||||
to: to.toString(),
|
||||
amount: amount.toBigInt(),
|
||||
};
|
||||
}
|
||||
|
||||
// Asset transfer (PEZ)
|
||||
if (token === "PEZ" && event.section === "assets" && event.method === "Transferred") {
|
||||
const [_assetId, from, to, amount] = event.data as [
|
||||
unknown,
|
||||
{ toString: () => string },
|
||||
{ toString: () => string },
|
||||
{ toBigInt: () => bigint }
|
||||
];
|
||||
return {
|
||||
from: from.toString(),
|
||||
to: to.toString(),
|
||||
amount: amount.toBigInt(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
serve(async (req: Request) => {
|
||||
// CORS headers
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user from JWT
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unauthorized" }),
|
||||
{ status: 401, headers }
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Supabase client with user's JWT
|
||||
const supabaseAnon = createClient(
|
||||
SUPABASE_URL,
|
||||
Deno.env.get("SUPABASE_ANON_KEY")!,
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: authHeader },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Get current user
|
||||
const { data: { user }, error: userError } = await supabaseAnon.auth.getUser();
|
||||
if (userError || !user) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Authentication failed" }),
|
||||
{ status: 401, headers }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const { txHash, token, expectedAmount }: VerifyDepositRequest = await req.json();
|
||||
|
||||
// Validate input
|
||||
if (!txHash || !token || !expectedAmount) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing required fields: txHash, token, expectedAmount" }),
|
||||
{ status: 400, headers }
|
||||
);
|
||||
}
|
||||
|
||||
if (!["HEZ", "PEZ"].includes(token)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid token type" }),
|
||||
{ status: 400, headers }
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedAmount <= 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid amount" }),
|
||||
{ status: 400, headers }
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize service role client for database operations
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
||||
|
||||
// Check if this tx hash has already been processed
|
||||
const { data: existingDeposit } = await supabase
|
||||
.from("p2p_deposit_withdraw_requests")
|
||||
.select("id")
|
||||
.eq("blockchain_tx_hash", txHash)
|
||||
.single();
|
||||
|
||||
if (existingDeposit) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "This transaction has already been processed" }),
|
||||
{ status: 400, headers }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Verifying deposit: ${txHash} for ${expectedAmount} ${token}`);
|
||||
|
||||
// Connect to blockchain
|
||||
const provider = new WsProvider(RPC_ENDPOINT);
|
||||
const api = await ApiPromise.create({ provider });
|
||||
await api.isReady;
|
||||
|
||||
try {
|
||||
// Get block hash from transaction hash
|
||||
// Note: In Substrate, we need to search for the extrinsic
|
||||
// This is a simplified version - production should use indexer
|
||||
const extrinsicHash = txHash;
|
||||
|
||||
// Try to get the extrinsic status
|
||||
// In production, you'd use a block explorer API or indexer
|
||||
// For now, we'll search recent blocks
|
||||
|
||||
const currentBlock = await api.rpc.chain.getBlock();
|
||||
const currentBlockNumber = currentBlock.block.header.number.toNumber();
|
||||
|
||||
let foundTransfer = null;
|
||||
let blockHash = null;
|
||||
|
||||
// Search last 100 blocks for the transaction
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const blockNumber = currentBlockNumber - i;
|
||||
if (blockNumber < 0) break;
|
||||
|
||||
const hash = await api.rpc.chain.getBlockHash(blockNumber);
|
||||
const signedBlock = await api.rpc.chain.getBlock(hash);
|
||||
const allRecords = await api.query.system.events.at(hash);
|
||||
|
||||
// Check each extrinsic in the block
|
||||
signedBlock.block.extrinsics.forEach((extrinsic, index) => {
|
||||
if (extrinsic.hash.toHex() === extrinsicHash) {
|
||||
// Found the transaction! Get its events
|
||||
const events = (allRecords as unknown as Array<{ phase: { isApplyExtrinsic: boolean; asApplyExtrinsic: { eq: (n: number) => boolean } }; event: { section: string; method: string; data: unknown[] } }>)
|
||||
.filter(({ phase }) => phase.isApplyExtrinsic && phase.asApplyExtrinsic.eq(index))
|
||||
.map(({ event }) => ({ event: { section: event.section, method: event.method, data: event.data as unknown[] } }));
|
||||
|
||||
// Check if transaction was successful
|
||||
const success = events.some(
|
||||
({ event }) =>
|
||||
event.section === "system" && event.method === "ExtrinsicSuccess"
|
||||
);
|
||||
|
||||
if (success) {
|
||||
foundTransfer = findTransferInEvents(api, events, token);
|
||||
blockHash = hash.toHex();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (foundTransfer) break;
|
||||
}
|
||||
|
||||
if (!foundTransfer) {
|
||||
await api.disconnect();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Transaction not found or not finalized. Please wait a few minutes and try again.",
|
||||
}),
|
||||
{ status: 400, headers }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the transfer details
|
||||
const { to, amount } = foundTransfer;
|
||||
|
||||
// Check recipient is platform wallet
|
||||
if (to !== PLATFORM_WALLET_ADDRESS) {
|
||||
await api.disconnect();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Transaction recipient is not the platform wallet",
|
||||
}),
|
||||
{ status: 400, headers }
|
||||
);
|
||||
}
|
||||
|
||||
// Convert amount to human-readable
|
||||
const actualAmount = Number(amount) / Math.pow(10, DECIMALS);
|
||||
|
||||
// Check amount matches (with tolerance for rounding)
|
||||
const amountDiff = Math.abs(actualAmount - expectedAmount) / expectedAmount;
|
||||
if (amountDiff > AMOUNT_TOLERANCE) {
|
||||
await api.disconnect();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `Amount mismatch. Expected ${expectedAmount}, found ${actualAmount}`,
|
||||
}),
|
||||
{ status: 400, headers }
|
||||
);
|
||||
}
|
||||
|
||||
await api.disconnect();
|
||||
|
||||
// All checks passed! Credit the user's internal balance
|
||||
// Create deposit request record
|
||||
const { data: depositRequest, error: insertError } = await supabase
|
||||
.from("p2p_deposit_withdraw_requests")
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
request_type: "deposit",
|
||||
token,
|
||||
amount: actualAmount,
|
||||
wallet_address: foundTransfer.from,
|
||||
blockchain_tx_hash: txHash,
|
||||
status: "completed",
|
||||
processed_at: new Date().toISOString(),
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertError) {
|
||||
throw new Error(`Failed to create deposit record: ${insertError.message}`);
|
||||
}
|
||||
|
||||
// Credit internal balance using the process_deposit function
|
||||
const { data: balanceResult, error: balanceError } = await supabase.rpc(
|
||||
"process_deposit",
|
||||
{
|
||||
p_user_id: user.id,
|
||||
p_token: token,
|
||||
p_amount: actualAmount,
|
||||
p_tx_hash: txHash,
|
||||
}
|
||||
);
|
||||
|
||||
if (balanceError) {
|
||||
throw new Error(`Failed to credit balance: ${balanceError.message}`);
|
||||
}
|
||||
|
||||
// Parse result
|
||||
const result = typeof balanceResult === "string" ? JSON.parse(balanceResult) : balanceResult;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to credit balance");
|
||||
}
|
||||
|
||||
console.log(`Successfully verified deposit ${txHash}: ${actualAmount} ${token}`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: `Successfully deposited ${actualAmount} ${token}`,
|
||||
depositId: depositRequest.id,
|
||||
amount: actualAmount,
|
||||
token,
|
||||
txHash,
|
||||
blockHash,
|
||||
}),
|
||||
{ headers }
|
||||
);
|
||||
} catch (error) {
|
||||
await api.disconnect();
|
||||
throw error;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("Error verifying deposit:", errorMessage);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: errorMessage }),
|
||||
{ status: 500, headers }
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,547 @@
|
||||
-- =====================================================
|
||||
-- P2P INTERNAL LEDGER ESCROW SYSTEM (OKX Model)
|
||||
-- Migration: 014_p2p_internal_ledger_escrow.sql
|
||||
-- Date: 2025-12-11
|
||||
-- =====================================================
|
||||
--
|
||||
-- This migration implements OKX-style internal ledger escrow where:
|
||||
-- - Blockchain transactions ONLY occur at deposit/withdraw
|
||||
-- - P2P trades use internal database balance transfers
|
||||
-- - No blockchain transactions during actual P2P trading
|
||||
--
|
||||
|
||||
-- =====================================================
|
||||
-- 1. USER INTERNAL BALANCES TABLE
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_internal_balances (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL CHECK (token IN ('HEZ', 'PEZ')),
|
||||
available_balance DECIMAL(20, 12) NOT NULL DEFAULT 0 CHECK (available_balance >= 0),
|
||||
locked_balance DECIMAL(20, 12) NOT NULL DEFAULT 0 CHECK (locked_balance >= 0),
|
||||
total_deposited DECIMAL(20, 12) NOT NULL DEFAULT 0,
|
||||
total_withdrawn DECIMAL(20, 12) NOT NULL DEFAULT 0,
|
||||
last_deposit_at TIMESTAMPTZ,
|
||||
last_withdraw_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, token)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_internal_balances_user ON user_internal_balances(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_internal_balances_token ON user_internal_balances(token);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE user_internal_balances ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can view their own balances
|
||||
CREATE POLICY "Users can view own balances"
|
||||
ON user_internal_balances FOR SELECT
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- Only system can modify balances (via service role)
|
||||
-- No INSERT/UPDATE/DELETE policies for regular users
|
||||
|
||||
-- Admins can view all balances
|
||||
CREATE POLICY "Admins can view all balances"
|
||||
ON user_internal_balances FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE profiles.id = auth.uid()
|
||||
AND profiles.role IN ('admin', 'super_admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- 2. DEPOSIT/WITHDRAW REQUESTS TABLE
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS p2p_deposit_withdraw_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
request_type TEXT NOT NULL CHECK (request_type IN ('deposit', 'withdraw')),
|
||||
token TEXT NOT NULL CHECK (token IN ('HEZ', 'PEZ')),
|
||||
amount DECIMAL(20, 12) NOT NULL CHECK (amount > 0),
|
||||
wallet_address TEXT NOT NULL,
|
||||
blockchain_tx_hash TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed', 'cancelled')),
|
||||
processed_at TIMESTAMPTZ,
|
||||
processed_by UUID REFERENCES auth.users(id),
|
||||
error_message TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_deposit_withdraw_status ON p2p_deposit_withdraw_requests(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_deposit_withdraw_user ON p2p_deposit_withdraw_requests(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deposit_withdraw_type ON p2p_deposit_withdraw_requests(request_type);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE p2p_deposit_withdraw_requests ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can view their own requests
|
||||
CREATE POLICY "Users can view own requests"
|
||||
ON p2p_deposit_withdraw_requests FOR SELECT
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- Users can create their own requests
|
||||
CREATE POLICY "Users can create own requests"
|
||||
ON p2p_deposit_withdraw_requests FOR INSERT
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- Admins can view and manage all requests
|
||||
CREATE POLICY "Admins can manage all requests"
|
||||
ON p2p_deposit_withdraw_requests FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE profiles.id = auth.uid()
|
||||
AND profiles.role IN ('admin', 'super_admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- =====================================================
|
||||
-- 3. BALANCE TRANSACTION LOG (Audit Trail)
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS p2p_balance_transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL,
|
||||
transaction_type TEXT NOT NULL CHECK (transaction_type IN (
|
||||
'deposit', 'withdraw', 'escrow_lock', 'escrow_release',
|
||||
'escrow_refund', 'trade_receive', 'admin_adjustment'
|
||||
)),
|
||||
amount DECIMAL(20, 12) NOT NULL,
|
||||
balance_before DECIMAL(20, 12) NOT NULL,
|
||||
balance_after DECIMAL(20, 12) NOT NULL,
|
||||
reference_type TEXT, -- 'offer', 'trade', 'deposit_request', 'withdraw_request'
|
||||
reference_id UUID,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_tx_user ON p2p_balance_transactions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_tx_type ON p2p_balance_transactions(transaction_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_tx_created ON p2p_balance_transactions(created_at DESC);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE p2p_balance_transactions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can view their own transaction history
|
||||
CREATE POLICY "Users can view own transactions"
|
||||
ON p2p_balance_transactions FOR SELECT
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- =====================================================
|
||||
-- 4. LOCK ESCROW FUNCTION (Internal Balance)
|
||||
-- =====================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION lock_escrow_internal(
|
||||
p_user_id UUID,
|
||||
p_token TEXT,
|
||||
p_amount DECIMAL(20, 12),
|
||||
p_reference_type TEXT DEFAULT NULL,
|
||||
p_reference_id UUID DEFAULT NULL
|
||||
) RETURNS JSON AS $$
|
||||
DECLARE
|
||||
v_balance RECORD;
|
||||
v_balance_before DECIMAL(20, 12);
|
||||
BEGIN
|
||||
-- Lock user's balance row for update
|
||||
SELECT * INTO v_balance
|
||||
FROM user_internal_balances
|
||||
WHERE user_id = p_user_id AND token = p_token
|
||||
FOR UPDATE;
|
||||
|
||||
-- Check if balance exists
|
||||
IF v_balance IS NULL THEN
|
||||
RETURN json_build_object(
|
||||
'success', false,
|
||||
'error', 'No balance found for token ' || p_token || '. Please deposit first.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_balance_before := v_balance.available_balance;
|
||||
|
||||
-- Check sufficient balance
|
||||
IF v_balance.available_balance < p_amount THEN
|
||||
RETURN json_build_object(
|
||||
'success', false,
|
||||
'error', 'Insufficient balance. Available: ' || v_balance.available_balance || ' ' || p_token
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Move from available to locked
|
||||
UPDATE user_internal_balances
|
||||
SET
|
||||
available_balance = available_balance - p_amount,
|
||||
locked_balance = locked_balance + p_amount,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = p_user_id AND token = p_token;
|
||||
|
||||
-- Log the transaction
|
||||
INSERT INTO p2p_balance_transactions (
|
||||
user_id, token, transaction_type, amount,
|
||||
balance_before, balance_after, reference_type, reference_id,
|
||||
description
|
||||
) VALUES (
|
||||
p_user_id, p_token, 'escrow_lock', p_amount,
|
||||
v_balance_before, v_balance_before - p_amount, p_reference_type, p_reference_id,
|
||||
'Escrow locked for P2P offer'
|
||||
);
|
||||
|
||||
RETURN json_build_object(
|
||||
'success', true,
|
||||
'locked_amount', p_amount,
|
||||
'available_balance', v_balance_before - p_amount,
|
||||
'locked_balance', v_balance.locked_balance + p_amount
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- =====================================================
|
||||
-- 5. RELEASE ESCROW FUNCTION (Trade Completion)
|
||||
-- =====================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION release_escrow_internal(
|
||||
p_from_user_id UUID,
|
||||
p_to_user_id UUID,
|
||||
p_token TEXT,
|
||||
p_amount DECIMAL(20, 12),
|
||||
p_reference_type TEXT DEFAULT 'trade',
|
||||
p_reference_id UUID DEFAULT NULL
|
||||
) RETURNS JSON AS $$
|
||||
DECLARE
|
||||
v_from_balance RECORD;
|
||||
v_to_balance RECORD;
|
||||
v_from_balance_before DECIMAL(20, 12);
|
||||
v_to_balance_before DECIMAL(20, 12);
|
||||
BEGIN
|
||||
-- Lock seller's balance row
|
||||
SELECT * INTO v_from_balance
|
||||
FROM user_internal_balances
|
||||
WHERE user_id = p_from_user_id AND token = p_token
|
||||
FOR UPDATE;
|
||||
|
||||
-- Check seller has sufficient locked balance
|
||||
IF v_from_balance IS NULL OR v_from_balance.locked_balance < p_amount THEN
|
||||
RETURN json_build_object(
|
||||
'success', false,
|
||||
'error', 'Insufficient locked balance for release'
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_from_balance_before := v_from_balance.locked_balance;
|
||||
|
||||
-- Reduce seller's locked balance
|
||||
UPDATE user_internal_balances
|
||||
SET
|
||||
locked_balance = locked_balance - p_amount,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = p_from_user_id AND token = p_token;
|
||||
|
||||
-- Log seller's transaction
|
||||
INSERT INTO p2p_balance_transactions (
|
||||
user_id, token, transaction_type, amount,
|
||||
balance_before, balance_after, reference_type, reference_id,
|
||||
description
|
||||
) VALUES (
|
||||
p_from_user_id, p_token, 'escrow_release', -p_amount,
|
||||
v_from_balance_before, v_from_balance_before - p_amount, p_reference_type, p_reference_id,
|
||||
'Escrow released to buyer'
|
||||
);
|
||||
|
||||
-- Get or initialize buyer's balance
|
||||
SELECT available_balance INTO v_to_balance_before
|
||||
FROM user_internal_balances
|
||||
WHERE user_id = p_to_user_id AND token = p_token;
|
||||
|
||||
IF v_to_balance_before IS NULL THEN
|
||||
v_to_balance_before := 0;
|
||||
END IF;
|
||||
|
||||
-- Increase buyer's available balance (upsert)
|
||||
INSERT INTO user_internal_balances (user_id, token, available_balance)
|
||||
VALUES (p_to_user_id, p_token, p_amount)
|
||||
ON CONFLICT (user_id, token)
|
||||
DO UPDATE SET
|
||||
available_balance = user_internal_balances.available_balance + p_amount,
|
||||
updated_at = NOW();
|
||||
|
||||
-- Log buyer's transaction
|
||||
INSERT INTO p2p_balance_transactions (
|
||||
user_id, token, transaction_type, amount,
|
||||
balance_before, balance_after, reference_type, reference_id,
|
||||
description
|
||||
) VALUES (
|
||||
p_to_user_id, p_token, 'trade_receive', p_amount,
|
||||
v_to_balance_before, v_to_balance_before + p_amount, p_reference_type, p_reference_id,
|
||||
'Received from P2P trade'
|
||||
);
|
||||
|
||||
RETURN json_build_object(
|
||||
'success', true,
|
||||
'transferred_amount', p_amount,
|
||||
'from_user_id', p_from_user_id,
|
||||
'to_user_id', p_to_user_id
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- =====================================================
|
||||
-- 6. REFUND ESCROW FUNCTION (Trade Cancellation)
|
||||
-- =====================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION refund_escrow_internal(
|
||||
p_user_id UUID,
|
||||
p_token TEXT,
|
||||
p_amount DECIMAL(20, 12),
|
||||
p_reference_type TEXT DEFAULT 'trade',
|
||||
p_reference_id UUID DEFAULT NULL
|
||||
) RETURNS JSON AS $$
|
||||
DECLARE
|
||||
v_balance RECORD;
|
||||
v_locked_before DECIMAL(20, 12);
|
||||
BEGIN
|
||||
-- Lock user's balance row
|
||||
SELECT * INTO v_balance
|
||||
FROM user_internal_balances
|
||||
WHERE user_id = p_user_id AND token = p_token
|
||||
FOR UPDATE;
|
||||
|
||||
-- Check sufficient locked balance
|
||||
IF v_balance IS NULL OR v_balance.locked_balance < p_amount THEN
|
||||
RETURN json_build_object(
|
||||
'success', false,
|
||||
'error', 'Insufficient locked balance for refund'
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_locked_before := v_balance.locked_balance;
|
||||
|
||||
-- Move from locked back to available
|
||||
UPDATE user_internal_balances
|
||||
SET
|
||||
locked_balance = locked_balance - p_amount,
|
||||
available_balance = available_balance + p_amount,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = p_user_id AND token = p_token;
|
||||
|
||||
-- Log the transaction
|
||||
INSERT INTO p2p_balance_transactions (
|
||||
user_id, token, transaction_type, amount,
|
||||
balance_before, balance_after, reference_type, reference_id,
|
||||
description
|
||||
) VALUES (
|
||||
p_user_id, p_token, 'escrow_refund', p_amount,
|
||||
v_locked_before, v_locked_before - p_amount, p_reference_type, p_reference_id,
|
||||
'Escrow refunded (trade cancelled)'
|
||||
);
|
||||
|
||||
RETURN json_build_object(
|
||||
'success', true,
|
||||
'refunded_amount', p_amount,
|
||||
'available_balance', v_balance.available_balance + p_amount,
|
||||
'locked_balance', v_locked_before - p_amount
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- =====================================================
|
||||
-- 7. PROCESS DEPOSIT FUNCTION
|
||||
-- =====================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION process_deposit(
|
||||
p_user_id UUID,
|
||||
p_token TEXT,
|
||||
p_amount DECIMAL(20, 12),
|
||||
p_tx_hash TEXT,
|
||||
p_request_id UUID DEFAULT NULL
|
||||
) RETURNS JSON AS $$
|
||||
DECLARE
|
||||
v_balance_before DECIMAL(20, 12) := 0;
|
||||
BEGIN
|
||||
-- Get current balance
|
||||
SELECT available_balance INTO v_balance_before
|
||||
FROM user_internal_balances
|
||||
WHERE user_id = p_user_id AND token = p_token;
|
||||
|
||||
IF v_balance_before IS NULL THEN
|
||||
v_balance_before := 0;
|
||||
END IF;
|
||||
|
||||
-- Upsert balance
|
||||
INSERT INTO user_internal_balances (
|
||||
user_id, token, available_balance, total_deposited, last_deposit_at
|
||||
) VALUES (
|
||||
p_user_id, p_token, p_amount, p_amount, NOW()
|
||||
)
|
||||
ON CONFLICT (user_id, token)
|
||||
DO UPDATE SET
|
||||
available_balance = user_internal_balances.available_balance + p_amount,
|
||||
total_deposited = user_internal_balances.total_deposited + p_amount,
|
||||
last_deposit_at = NOW(),
|
||||
updated_at = NOW();
|
||||
|
||||
-- Log the transaction
|
||||
INSERT INTO p2p_balance_transactions (
|
||||
user_id, token, transaction_type, amount,
|
||||
balance_before, balance_after, reference_type, reference_id,
|
||||
description
|
||||
) VALUES (
|
||||
p_user_id, p_token, 'deposit', p_amount,
|
||||
v_balance_before, v_balance_before + p_amount, 'deposit_request', p_request_id,
|
||||
'Deposit from blockchain TX: ' || p_tx_hash
|
||||
);
|
||||
|
||||
-- Update request status if provided
|
||||
IF p_request_id IS NOT NULL THEN
|
||||
UPDATE p2p_deposit_withdraw_requests
|
||||
SET
|
||||
status = 'completed',
|
||||
blockchain_tx_hash = p_tx_hash,
|
||||
processed_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = p_request_id;
|
||||
END IF;
|
||||
|
||||
RETURN json_build_object(
|
||||
'success', true,
|
||||
'deposited_amount', p_amount,
|
||||
'new_balance', v_balance_before + p_amount
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- =====================================================
|
||||
-- 8. REQUEST WITHDRAW FUNCTION
|
||||
-- =====================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION request_withdraw(
|
||||
p_user_id UUID,
|
||||
p_token TEXT,
|
||||
p_amount DECIMAL(20, 12),
|
||||
p_wallet_address TEXT
|
||||
) RETURNS JSON AS $$
|
||||
DECLARE
|
||||
v_balance RECORD;
|
||||
v_request_id UUID;
|
||||
BEGIN
|
||||
-- Lock user's balance
|
||||
SELECT * INTO v_balance
|
||||
FROM user_internal_balances
|
||||
WHERE user_id = p_user_id AND token = p_token
|
||||
FOR UPDATE;
|
||||
|
||||
-- Check sufficient available balance
|
||||
IF v_balance IS NULL OR v_balance.available_balance < p_amount THEN
|
||||
RETURN json_build_object(
|
||||
'success', false,
|
||||
'error', 'Insufficient available balance. Available: ' || COALESCE(v_balance.available_balance, 0)
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Lock the amount (move to locked_balance)
|
||||
UPDATE user_internal_balances
|
||||
SET
|
||||
available_balance = available_balance - p_amount,
|
||||
locked_balance = locked_balance + p_amount,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = p_user_id AND token = p_token;
|
||||
|
||||
-- Create withdrawal request
|
||||
INSERT INTO p2p_deposit_withdraw_requests (
|
||||
user_id, request_type, token, amount, wallet_address, status
|
||||
) VALUES (
|
||||
p_user_id, 'withdraw', p_token, p_amount, p_wallet_address, 'pending'
|
||||
) RETURNING id INTO v_request_id;
|
||||
|
||||
-- Log the transaction
|
||||
INSERT INTO p2p_balance_transactions (
|
||||
user_id, token, transaction_type, amount,
|
||||
balance_before, balance_after, reference_type, reference_id,
|
||||
description
|
||||
) VALUES (
|
||||
p_user_id, p_token, 'withdraw', -p_amount,
|
||||
v_balance.available_balance, v_balance.available_balance - p_amount, 'withdraw_request', v_request_id,
|
||||
'Withdrawal request to ' || p_wallet_address
|
||||
);
|
||||
|
||||
RETURN json_build_object(
|
||||
'success', true,
|
||||
'request_id', v_request_id,
|
||||
'amount', p_amount,
|
||||
'wallet_address', p_wallet_address,
|
||||
'status', 'pending'
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- =====================================================
|
||||
-- 9. GET USER BALANCE FUNCTION
|
||||
-- =====================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_user_internal_balance(p_user_id UUID)
|
||||
RETURNS JSON AS $$
|
||||
DECLARE
|
||||
v_balances JSON;
|
||||
BEGIN
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'token', token,
|
||||
'available_balance', available_balance,
|
||||
'locked_balance', locked_balance,
|
||||
'total_balance', available_balance + locked_balance,
|
||||
'total_deposited', total_deposited,
|
||||
'total_withdrawn', total_withdrawn
|
||||
)
|
||||
) INTO v_balances
|
||||
FROM user_internal_balances
|
||||
WHERE user_id = p_user_id;
|
||||
|
||||
RETURN COALESCE(v_balances, '[]'::json);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- =====================================================
|
||||
-- 10. HELPER: UPDATE UPDATED_AT TRIGGER
|
||||
-- =====================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Apply trigger to tables
|
||||
DROP TRIGGER IF EXISTS update_user_internal_balances_updated_at ON user_internal_balances;
|
||||
CREATE TRIGGER update_user_internal_balances_updated_at
|
||||
BEFORE UPDATE ON user_internal_balances
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_deposit_withdraw_requests_updated_at ON p2p_deposit_withdraw_requests;
|
||||
CREATE TRIGGER update_deposit_withdraw_requests_updated_at
|
||||
BEFORE UPDATE ON p2p_deposit_withdraw_requests
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- =====================================================
|
||||
-- COMMENTS
|
||||
-- =====================================================
|
||||
|
||||
COMMENT ON TABLE user_internal_balances IS 'User internal balances for P2P trading (OKX-style internal ledger)';
|
||||
COMMENT ON TABLE p2p_deposit_withdraw_requests IS 'Requests for deposits/withdrawals that require blockchain transactions';
|
||||
COMMENT ON TABLE p2p_balance_transactions IS 'Audit log of all balance changes';
|
||||
COMMENT ON FUNCTION lock_escrow_internal IS 'Lock user balance for P2P escrow (internal ledger)';
|
||||
COMMENT ON FUNCTION release_escrow_internal IS 'Release escrow to buyer on trade completion';
|
||||
COMMENT ON FUNCTION refund_escrow_internal IS 'Refund escrow to seller on trade cancellation';
|
||||
COMMENT ON FUNCTION process_deposit IS 'Credit user balance after deposit verification';
|
||||
COMMENT ON FUNCTION request_withdraw IS 'Create withdrawal request and lock balance';
|
||||
Reference in New Issue
Block a user