mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-26 15:35:41 +00:00
a8f41cd47f
Critical/high audit remediation on the custodial P2P ledger. Auth model is wallet-based; identity (citizen/visa) is cryptographically bound to a wallet (People Chain tiki.citizenNft / active p2p_visa), which enables a correct fix. - Withdrawal BOLA (CRITICAL): process-withdraw now requires a wallet-SIGNED challenge; server verifies signature (raw + <Bytes> forms), asserts the signer OWNS the identity, consumes a single-use nonce (replay), and derives user_id server-side — the client-supplied user_id is ignored. process-withdrawal (batch) now requires the service-role key (was: any bearer, incl. public anon key). request_withdraw is REVOKEd from anon/authenticated + service-role guarded. - Financial RLS (HIGH): drop the blanket USING(true) SELECT on user_internal_balances, p2p_balance_transactions, p2p_deposit_withdraw_requests; lock p2p_user_payment_methods (IBAN PII) + p2p_fiat_disputes UPDATE to service_role; legitimate reads move behind scoped SECURITY DEFINER RPCs. - Deposit integrity: verify-deposit now binds the on-chain sender to identity ownership before crediting. - Admin dispute (CRITICAL fund-logic): DisputeResolutionPanel relabeled trades without moving escrow. New admin-signed resolve-dispute function + admin_resolve_dispute RPC moves escrow (release/refund/split) atomically with correct accounting (avoids the double-count in the legacy resolve_p2p_dispute). Client isAdmin documented as cosmetic. DEPLOY RUNBOOK (gated; owner runs): 1) apply migrations 20260225/20260725* in order; 2) deploy edge functions process-withdraw, process-withdrawal, verify-deposit, resolve-dispute; 3) set edge secrets PEOPLE_RPC_ENDPOINT (+ optional ADMIN_WALLETS); 4) ship frontend. Migrations + functions + frontend must go together or the app breaks. KNOWN RESIDUAL (Round 2 — as severe as the withdrawal BOLA): release_/lock_/refund_ escrow_internal still have PUBLIC EXECUTE and the client calls release_escrow_internal directly with the anon key from confirmPaymentReceived -> an anon caller can drain any victim's LOCKED balance. Fix = a wallet-signed confirm-payment edge function (same pattern as withdrawals) before revoking PUBLIC execute. Not yet fixed.
159 lines
7.4 KiB
PL/PgSQL
159 lines
7.4 KiB
PL/PgSQL
-- =====================================================
|
|
-- Migration: Atomic admin dispute resolution (moves escrow)
|
|
-- Date: 2026-07-25
|
|
-- =====================================================
|
|
--
|
|
-- Fix for the CRITICAL fund-logic finding: the admin Dispute Resolution panel
|
|
-- only relabelled p2p_fiat_trades.status and NEVER moved escrow, so "buyer wins"
|
|
-- paid nothing, "refund seller" left funds locked, and "split" was unhandled.
|
|
--
|
|
-- This SECURITY DEFINER function performs the escrow movement AND all status
|
|
-- changes in a single transaction. It is service-role only; caller identity
|
|
-- (admin) is verified by the resolve-dispute edge function via a wallet
|
|
-- signature before this is invoked.
|
|
--
|
|
-- ESCROW ACCOUNTING (verified against createFiatOffer + accept_p2p_offer):
|
|
-- * createFiatOffer locks the FULL offer amount (available -> locked).
|
|
-- * accept_p2p_offer does NOT lock again; it only decrements
|
|
-- offer.remaining_amount. So: seller.locked == remaining_amount + SUM(active
|
|
-- trade amounts) for the offer.
|
|
-- Therefore, per trade of amount X:
|
|
-- - release_to_buyer : locked(seller) -= X, available(buyer) += X. (offer untouched)
|
|
-- - refund_to_seller : locked(seller) -= X, available(seller) += X. (offer untouched)
|
|
-- - split : release X/2 to buyer + refund X/2 to seller. (offer untouched)
|
|
-- remaining_amount is intentionally NOT restored (that would double-count the
|
|
-- funds — a latent bug in the older resolve_p2p_dispute()).
|
|
|
|
CREATE OR REPLACE FUNCTION public.admin_resolve_dispute(
|
|
p_dispute_id UUID,
|
|
p_trade_id UUID,
|
|
p_decision TEXT, -- 'release_to_buyer' | 'refund_to_seller' | 'split' | 'escalate'
|
|
p_reasoning TEXT,
|
|
p_admin_ref TEXT -- admin wallet address (for audit only)
|
|
) RETURNS JSON AS $$
|
|
DECLARE
|
|
v_trade RECORD;
|
|
v_token TEXT;
|
|
v_half DECIMAL(20, 12);
|
|
v_res JSON;
|
|
v_trade_status TEXT;
|
|
BEGIN
|
|
-- SECURITY: backend service role only. Admin identity is verified upstream by
|
|
-- the resolve-dispute edge function (wallet signature vs. admin wallet set).
|
|
IF current_setting('role', true) <> 'service_role'
|
|
AND current_setting('request.jwt.claim.role', true) <> 'service_role' THEN
|
|
RETURN json_build_object('success', false, 'error', 'UNAUTHORIZED: service role required');
|
|
END IF;
|
|
|
|
IF p_reasoning IS NULL OR length(trim(p_reasoning)) = 0 THEN
|
|
RETURN json_build_object('success', false, 'error', 'Reasoning is required');
|
|
END IF;
|
|
|
|
IF p_decision NOT IN ('release_to_buyer', 'refund_to_seller', 'split', 'escalate') THEN
|
|
RETURN json_build_object('success', false, 'error', 'Invalid decision');
|
|
END IF;
|
|
|
|
-- Lock the trade row
|
|
SELECT * INTO v_trade FROM public.p2p_fiat_trades WHERE id = p_trade_id FOR UPDATE;
|
|
IF NOT FOUND THEN
|
|
RETURN json_build_object('success', false, 'error', 'Trade not found');
|
|
END IF;
|
|
|
|
-- ----- ESCALATE: no fund movement, just mark dispute escalated -----
|
|
IF p_decision = 'escalate' THEN
|
|
UPDATE public.p2p_fiat_disputes
|
|
SET status = 'escalated', decision = 'escalate', decision_reasoning = p_reasoning, updated_at = NOW()
|
|
WHERE id = p_dispute_id;
|
|
|
|
INSERT INTO public.p2p_audit_log (user_id, action, entity_type, entity_id, details)
|
|
VALUES (NULL, 'dispute_escalated', 'trade', p_trade_id,
|
|
jsonb_build_object('dispute_id', p_dispute_id, 'admin_ref', p_admin_ref, 'reasoning', p_reasoning));
|
|
|
|
RETURN json_build_object('success', true, 'decision', 'escalate');
|
|
END IF;
|
|
|
|
-- For fund-moving decisions the trade must be in dispute.
|
|
IF v_trade.status <> 'disputed' THEN
|
|
RETURN json_build_object('success', false, 'error', 'Trade is not in disputed status (current: ' || v_trade.status || ')');
|
|
END IF;
|
|
|
|
-- Resolve token from the offer
|
|
SELECT token INTO v_token FROM public.p2p_fiat_offers WHERE id = v_trade.offer_id;
|
|
IF v_token IS NULL THEN
|
|
RETURN json_build_object('success', false, 'error', 'Offer/token not found for trade');
|
|
END IF;
|
|
|
|
IF p_decision = 'release_to_buyer' THEN
|
|
v_res := public.release_escrow_internal(
|
|
v_trade.seller_id, v_trade.buyer_id, v_token, v_trade.crypto_amount, 'dispute_resolution', p_trade_id);
|
|
IF (v_res->>'success')::boolean IS NOT TRUE THEN
|
|
RAISE EXCEPTION 'release_escrow_internal failed: %', COALESCE(v_res->>'error', 'unknown');
|
|
END IF;
|
|
v_trade_status := 'completed';
|
|
|
|
ELSIF p_decision = 'refund_to_seller' THEN
|
|
v_res := public.refund_escrow_internal(
|
|
v_trade.seller_id, v_token, v_trade.crypto_amount, 'dispute_resolution', p_trade_id);
|
|
IF (v_res->>'success')::boolean IS NOT TRUE THEN
|
|
RAISE EXCEPTION 'refund_escrow_internal failed: %', COALESCE(v_res->>'error', 'unknown');
|
|
END IF;
|
|
v_trade_status := 'refunded';
|
|
|
|
ELSIF p_decision = 'split' THEN
|
|
v_half := ROUND(v_trade.crypto_amount / 2, 12);
|
|
-- Half to buyer
|
|
v_res := public.release_escrow_internal(
|
|
v_trade.seller_id, v_trade.buyer_id, v_token, v_half, 'dispute_resolution_split', p_trade_id);
|
|
IF (v_res->>'success')::boolean IS NOT TRUE THEN
|
|
RAISE EXCEPTION 'split release failed: %', COALESCE(v_res->>'error', 'unknown');
|
|
END IF;
|
|
-- Remainder back to seller (handles odd cents deterministically)
|
|
v_res := public.refund_escrow_internal(
|
|
v_trade.seller_id, v_token, v_trade.crypto_amount - v_half, 'dispute_resolution_split', p_trade_id);
|
|
IF (v_res->>'success')::boolean IS NOT TRUE THEN
|
|
RAISE EXCEPTION 'split refund failed: %', COALESCE(v_res->>'error', 'unknown');
|
|
END IF;
|
|
v_trade_status := 'completed';
|
|
END IF;
|
|
|
|
-- Update trade
|
|
UPDATE public.p2p_fiat_trades
|
|
SET status = v_trade_status,
|
|
completed_at = CASE WHEN v_trade_status = 'completed' THEN NOW() ELSE completed_at END,
|
|
escrow_released_at = NOW(),
|
|
dispute_resolved_at = NOW(),
|
|
dispute_resolution = p_decision || ': ' || COALESCE(p_reasoning, ''),
|
|
updated_at = NOW()
|
|
WHERE id = p_trade_id;
|
|
|
|
-- Update dispute
|
|
UPDATE public.p2p_fiat_disputes
|
|
SET status = 'resolved', decision = p_decision, decision_reasoning = p_reasoning,
|
|
resolved_at = NOW(), updated_at = NOW()
|
|
WHERE id = p_dispute_id;
|
|
|
|
-- Notify both parties (best-effort, same txn)
|
|
INSERT INTO public.p2p_notifications (user_id, type, title, message, reference_type, reference_id)
|
|
VALUES
|
|
(v_trade.seller_id, 'dispute_resolved', 'Dispute Resolved', 'Your dispute was resolved: ' || p_decision, 'dispute', p_dispute_id),
|
|
(v_trade.buyer_id, 'dispute_resolved', 'Dispute Resolved', 'Your dispute was resolved: ' || p_decision, 'dispute', p_dispute_id);
|
|
|
|
-- Audit
|
|
INSERT INTO public.p2p_audit_log (user_id, action, entity_type, entity_id, details)
|
|
VALUES (NULL, 'dispute_resolved', 'trade', p_trade_id,
|
|
jsonb_build_object(
|
|
'dispute_id', p_dispute_id, 'decision', p_decision, 'admin_ref', p_admin_ref,
|
|
'reasoning', p_reasoning, 'seller_id', v_trade.seller_id, 'buyer_id', v_trade.buyer_id,
|
|
'amount', v_trade.crypto_amount, 'token', v_token));
|
|
|
|
RETURN json_build_object('success', true, 'decision', p_decision, 'trade_id', p_trade_id, 'token', v_token, 'amount', v_trade.crypto_amount);
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
REVOKE EXECUTE ON FUNCTION public.admin_resolve_dispute(UUID, UUID, TEXT, TEXT, TEXT) FROM PUBLIC, anon, authenticated;
|
|
|
|
COMMENT ON FUNCTION public.admin_resolve_dispute IS
|
|
'Atomically resolves a P2P dispute: moves escrow (release/refund/split) AND
|
|
updates trade + dispute status + notifications in one transaction. SERVICE ROLE
|
|
ONLY — admin identity is verified by the resolve-dispute edge function.';
|