Files
pwap/web/supabase/migrations/014_p2p_internal_ledger_escrow.sql
T
pezkuwichain 14f5e84d15 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
2025-12-12 00:41:11 +03:00

548 lines
18 KiB
PL/PgSQL

-- =====================================================
-- 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';