mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-26 08:35:41 +00:00
security(p2p): fix withdrawal BOLA, financial RLS exposure, admin dispute escrow
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.
This commit is contained in:
+74
-21
@@ -1023,18 +1023,53 @@ export async function getInternalBalance(userId: string, token: CryptoToken): Pr
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a withdrawal from internal balance to external wallet
|
||||
* Calls the process-withdraw edge function which handles the full flow:
|
||||
* limit check → lock balance → blockchain TX → complete withdrawal
|
||||
* Canonical withdrawal challenge string. MUST be byte-identical to the server's
|
||||
* buildWithdrawChallenge (web/supabase/functions/_shared/identity-auth.ts).
|
||||
*/
|
||||
export function buildWithdrawChallenge(p: {
|
||||
identityId: string;
|
||||
token: string;
|
||||
amount: number;
|
||||
destination: string;
|
||||
signerAddress: string;
|
||||
timestamp: number;
|
||||
nonce: string;
|
||||
}): string {
|
||||
return [
|
||||
'Pezkuwi P2P Withdrawal',
|
||||
`identity:${p.identityId}`,
|
||||
`token:${p.token}`,
|
||||
`amount:${p.amount}`,
|
||||
`destination:${p.destination}`,
|
||||
`signer:${p.signerAddress}`,
|
||||
`timestamp:${p.timestamp}`,
|
||||
`nonce:${p.nonce}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a withdrawal from internal balance to external wallet.
|
||||
*
|
||||
* AUTHORIZATION: the caller must prove ownership of the P2P identity by signing
|
||||
* a challenge with the wallet that owns it (citizen NFT / visa). The edge
|
||||
* function derives the user_id server-side from `identityId` after verifying the
|
||||
* signature and identity ownership — a client-supplied user_id is never trusted.
|
||||
*
|
||||
* @param identityId Citizen number (#42-<item>-<6d>) or visa number (V-XXXXXX)
|
||||
* @param signerAddress The connected wallet (must own the identity)
|
||||
* @param signMessage Signs a message with the connected wallet (signRaw)
|
||||
*/
|
||||
export async function requestWithdraw(
|
||||
userId: string,
|
||||
identityId: string,
|
||||
token: CryptoToken,
|
||||
amount: number,
|
||||
walletAddress: string
|
||||
walletAddress: string,
|
||||
signerAddress: string,
|
||||
signMessage: (message: string) => Promise<string>
|
||||
): Promise<string> {
|
||||
try {
|
||||
if (!userId) throw new Error('Identity required for P2P trading');
|
||||
if (!identityId) throw new Error('Identity required for P2P trading');
|
||||
if (!signerAddress) throw new Error('Connect a wallet to withdraw');
|
||||
|
||||
// Validate amount
|
||||
if (amount <= 0) throw new Error('Amount must be greater than 0');
|
||||
@@ -1044,10 +1079,26 @@ export async function requestWithdraw(
|
||||
throw new Error('Invalid wallet address');
|
||||
}
|
||||
|
||||
// Build + sign the authorization challenge binding the full withdrawal intent.
|
||||
const timestamp = Date.now();
|
||||
const nonce = `wd-${timestamp}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
||||
const challenge = buildWithdrawChallenge({
|
||||
identityId,
|
||||
token,
|
||||
amount,
|
||||
destination: walletAddress,
|
||||
signerAddress,
|
||||
timestamp,
|
||||
nonce,
|
||||
});
|
||||
|
||||
toast.info('Sign the request in your wallet to authorize the withdrawal...');
|
||||
const signature = await signMessage(challenge);
|
||||
|
||||
toast.info('Processing withdrawal...');
|
||||
|
||||
const { data, error, response } = await supabase.functions.invoke('process-withdraw', {
|
||||
body: { userId, token, amount, walletAddress }
|
||||
body: { identityId, token, amount, walletAddress, signerAddress, signature, timestamp, nonce }
|
||||
});
|
||||
|
||||
// Supabase client wraps non-2xx as generic FunctionsHttpError (data=null).
|
||||
@@ -1085,15 +1136,14 @@ export async function getDepositWithdrawHistory(userId: string): Promise<Deposit
|
||||
try {
|
||||
if (!userId) throw new Error('Identity required for P2P trading');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_deposit_withdraw_requests')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
// Scoped SECURITY DEFINER RPC (direct table SELECT is no longer world-open).
|
||||
const { data, error } = await supabase.rpc('get_user_deposit_withdraw_requests', {
|
||||
p_user_id: userId,
|
||||
p_limit: 50,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
return (data as DepositWithdrawRequest[]) || [];
|
||||
} catch (error) {
|
||||
console.error('Get deposit/withdraw history error:', error);
|
||||
return [];
|
||||
@@ -1103,16 +1153,19 @@ export async function getDepositWithdrawHistory(userId: string): Promise<Deposit
|
||||
/**
|
||||
* Get user's balance transaction history
|
||||
*/
|
||||
export async function getBalanceTransactionHistory(limit: number = 50): Promise<BalanceTransaction[]> {
|
||||
export async function getBalanceTransactionHistory(userId: string, limit: number = 50): Promise<BalanceTransaction[]> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_balance_transactions')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit);
|
||||
if (!userId) throw new Error('Identity required for P2P trading');
|
||||
|
||||
// Scoped SECURITY DEFINER RPC — returns only this user's ledger rows
|
||||
// (the previous unfiltered SELECT * exposed every user's transactions).
|
||||
const { data, error } = await supabase.rpc('get_user_balance_transactions', {
|
||||
p_user_id: userId,
|
||||
p_limit: limit,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
return (data as BalanceTransaction[]) || [];
|
||||
} catch (error) {
|
||||
console.error('Get balance transaction history error:', error);
|
||||
return [];
|
||||
|
||||
@@ -151,6 +151,12 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// NOTE: `isAdmin` here is a COSMETIC UX gate only (derived from a localStorage
|
||||
// wallet and forgeable in DevTools). It prevents casual access to the admin UI
|
||||
// but is NOT a security boundary. All privileged operations behind this route
|
||||
// (e.g. dispute resolution) independently verify admin authority server-side
|
||||
// via a wallet signature (resolve-dispute edge function). Never rely on this
|
||||
// check alone to protect funds or state.
|
||||
if (requireAdmin && !isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -111,8 +113,35 @@ const CATEGORY_KEYS: Record<string, string> = {
|
||||
other: 'dispute.categoryOther'
|
||||
};
|
||||
|
||||
/**
|
||||
* Canonical admin-action challenge. MUST be byte-identical to the server
|
||||
* buildAdminChallenge (web/supabase/functions/_shared/identity-auth.ts).
|
||||
*/
|
||||
function buildAdminChallenge(p: {
|
||||
action: string;
|
||||
disputeId: string;
|
||||
tradeId: string;
|
||||
decision: string;
|
||||
adminAddress: string;
|
||||
timestamp: number;
|
||||
nonce: string;
|
||||
}): string {
|
||||
return [
|
||||
'Pezkuwi P2P Admin Action',
|
||||
`action:${p.action}`,
|
||||
`dispute:${p.disputeId}`,
|
||||
`trade:${p.tradeId}`,
|
||||
`decision:${p.decision}`,
|
||||
`admin:${p.adminAddress}`,
|
||||
`timestamp:${p.timestamp}`,
|
||||
`nonce:${p.nonce}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function DisputeResolutionPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { selectedAccount } = usePezkuwi();
|
||||
const { signMessage } = useWallet();
|
||||
const [disputes, setDisputes] = useState<Dispute[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedDispute, setSelectedDispute] = useState<Dispute | null>(null);
|
||||
@@ -202,28 +231,46 @@ export function DisputeResolutionPanel() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Claim dispute for review
|
||||
const claimDispute = async (disputeId: string) => {
|
||||
// Sign an admin-action challenge with the connected wallet. The server
|
||||
// (resolve-dispute edge function) verifies the signature AND that the wallet
|
||||
// is in the admin set — this is the real authorization, not the client flag.
|
||||
const signAdminAction = async (
|
||||
action: 'claim' | 'resolve',
|
||||
disputeId: string,
|
||||
tradeId: string,
|
||||
decision: string
|
||||
) => {
|
||||
if (!selectedAccount?.address) throw new Error('Connect an admin wallet');
|
||||
const timestamp = Date.now();
|
||||
const nonce = `adm-${timestamp}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
||||
const challenge = buildAdminChallenge({
|
||||
action,
|
||||
disputeId,
|
||||
tradeId,
|
||||
decision,
|
||||
adminAddress: selectedAccount.address,
|
||||
timestamp,
|
||||
nonce,
|
||||
});
|
||||
const signature = await signMessage(challenge);
|
||||
return { adminAddress: selectedAccount.address, signature, timestamp, nonce };
|
||||
};
|
||||
|
||||
// Claim dispute for review (server-verified admin action)
|
||||
const claimDispute = async (disputeId: string, tradeId: string) => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
const { error } = await supabase
|
||||
.from('p2p_fiat_disputes')
|
||||
.update({
|
||||
status: 'under_review',
|
||||
assigned_moderator_id: user.id,
|
||||
assigned_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', disputeId);
|
||||
|
||||
if (error) throw error;
|
||||
toast.info(t('dispute.claimSignPrompt', 'Sign to claim this dispute...'));
|
||||
const auth = await signAdminAction('claim', disputeId, tradeId, '');
|
||||
const { data, error } = await supabase.functions.invoke('resolve-dispute', {
|
||||
body: { action: 'claim', disputeId, tradeId, ...auth },
|
||||
});
|
||||
if (error || !data?.success) throw new Error(data?.error || error?.message || 'Claim failed');
|
||||
|
||||
toast.success(t('dispute.claimedToast'));
|
||||
fetchDisputes();
|
||||
} catch (error) {
|
||||
console.error('Error claiming dispute:', error);
|
||||
toast.error(t('dispute.claimFailed'));
|
||||
toast.error(error instanceof Error ? error.message : t('dispute.claimFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -236,52 +283,26 @@ export function DisputeResolutionPanel() {
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
// Server-verified admin action. The edge function verifies the admin
|
||||
// signature, then atomically MOVES ESCROW (release/refund/split) and
|
||||
// updates trade + dispute status in one transaction. The client no longer
|
||||
// relabels rows directly (which previously moved no funds).
|
||||
toast.info(t('dispute.resolveSignPrompt', 'Sign to authorize the resolution...'));
|
||||
const auth = await signAdminAction('resolve', selectedDispute.id, selectedDispute.trade_id, decision);
|
||||
|
||||
// Update dispute
|
||||
const { error: disputeError } = await supabase
|
||||
.from('p2p_fiat_disputes')
|
||||
.update({
|
||||
status: decision === 'escalate' ? 'escalated' : 'resolved',
|
||||
const { data, error } = await supabase.functions.invoke('resolve-dispute', {
|
||||
body: {
|
||||
action: 'resolve',
|
||||
disputeId: selectedDispute.id,
|
||||
tradeId: selectedDispute.trade_id,
|
||||
decision,
|
||||
decision_reasoning: reasoning,
|
||||
resolved_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', selectedDispute.id);
|
||||
reasoning,
|
||||
...auth,
|
||||
},
|
||||
});
|
||||
|
||||
if (disputeError) throw disputeError;
|
||||
|
||||
// Update trade status based on decision
|
||||
if (decision !== 'escalate' && selectedDispute.trade) {
|
||||
const tradeStatus = decision === 'release_to_buyer' ? 'completed' : 'refunded';
|
||||
await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.update({ status: tradeStatus })
|
||||
.eq('id', selectedDispute.trade_id);
|
||||
}
|
||||
|
||||
// Create notifications for both parties
|
||||
if (selectedDispute.trade) {
|
||||
const notificationPromises = [
|
||||
supabase.rpc('create_p2p_notification', {
|
||||
p_user_id: selectedDispute.trade.seller_id,
|
||||
p_type: 'dispute_resolved',
|
||||
p_title: 'Dispute Resolved',
|
||||
p_message: `The dispute has been resolved: ${t(DECISION_OPTION_KEYS.find(o => o.value === decision)?.labelKey || '')}`,
|
||||
p_reference_type: 'dispute',
|
||||
p_reference_id: selectedDispute.id
|
||||
}),
|
||||
supabase.rpc('create_p2p_notification', {
|
||||
p_user_id: selectedDispute.trade.buyer_id,
|
||||
p_type: 'dispute_resolved',
|
||||
p_title: 'Dispute Resolved',
|
||||
p_message: `The dispute has been resolved: ${t(DECISION_OPTION_KEYS.find(o => o.value === decision)?.labelKey || '')}`,
|
||||
p_reference_type: 'dispute',
|
||||
p_reference_id: selectedDispute.id
|
||||
})
|
||||
];
|
||||
await Promise.all(notificationPromises);
|
||||
if (error || !data?.success) {
|
||||
throw new Error(data?.error || error?.message || 'Resolution failed');
|
||||
}
|
||||
|
||||
toast.success(t('dispute.resolvedToast'));
|
||||
@@ -292,7 +313,7 @@ export function DisputeResolutionPanel() {
|
||||
fetchDisputes();
|
||||
} catch (error) {
|
||||
console.error('Error resolving dispute:', error);
|
||||
toast.error(t('dispute.resolveFailed'));
|
||||
toast.error(error instanceof Error ? error.message : t('dispute.resolveFailed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -475,7 +496,7 @@ export function DisputeResolutionPanel() {
|
||||
{dispute.status === 'open' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => claimDispute(dispute.id)}
|
||||
onClick={() => claimDispute(dispute.id, dispute.trade_id)}
|
||||
>
|
||||
{t('dispute.claim')}
|
||||
</Button>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -52,7 +53,8 @@ type WithdrawStep = 'form' | 'confirm' | 'success';
|
||||
export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedAccount } = usePezkuwi();
|
||||
const { userId } = useP2PIdentity();
|
||||
const { signMessage } = useWallet();
|
||||
const { userId, identityId } = useP2PIdentity();
|
||||
|
||||
const [step, setStep] = useState<WithdrawStep>('form');
|
||||
const [token, setToken] = useState<CryptoToken>('HEZ');
|
||||
@@ -177,8 +179,18 @@ export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps
|
||||
|
||||
try {
|
||||
const withdrawAmount = parseFloat(amount);
|
||||
if (!userId) throw new Error('Identity required');
|
||||
const id = await requestWithdraw(userId, token, withdrawAmount, walletAddress);
|
||||
if (!identityId) throw new Error('Identity required');
|
||||
if (!selectedAccount?.address) throw new Error('Connect a wallet to withdraw');
|
||||
// Authorization: sign a challenge with the wallet that owns the identity.
|
||||
// The edge function derives user_id from identityId after verifying this.
|
||||
const id = await requestWithdraw(
|
||||
identityId,
|
||||
token,
|
||||
withdrawAmount,
|
||||
walletAddress,
|
||||
selectedAccount.address,
|
||||
signMessage
|
||||
);
|
||||
setRequestId(id);
|
||||
setStep('success');
|
||||
onSuccess?.();
|
||||
|
||||
@@ -115,6 +115,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
|
||||
const checkAdminStatus = useCallback(async () => {
|
||||
// ============================================================
|
||||
// COSMETIC ONLY — NOT an authorization boundary.
|
||||
// ------------------------------------------------------------
|
||||
// This flag is derived from a localStorage wallet value and can be trivially
|
||||
// forged in DevTools. It ONLY controls whether admin UI is shown. Every
|
||||
// privileged operation MUST be verified server-side:
|
||||
// - Dispute claim/resolve -> `resolve-dispute` edge function verifies a
|
||||
// wallet SIGNATURE against the server-side admin wallet set before doing
|
||||
// anything (see supabase/functions/resolve-dispute + _shared/identity-auth).
|
||||
// Do NOT add fund-moving or state-changing logic gated solely on isAdmin.
|
||||
// ============================================================
|
||||
// Admin wallet whitelist (blockchain-based auth)
|
||||
const ADMIN_WALLETS = [
|
||||
'5CyuFfbF95rzBxru7c9yEsX4XmQXUxpLUcbj9RLg9K1cGiiF', // Founder
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// _shared/identity-auth.ts
|
||||
//
|
||||
// Server-side identity + wallet-signature authorization for fund-moving edge
|
||||
// functions (withdrawals, deposits). This is the security boundary that binds
|
||||
// an incoming request to a real, cryptographically-proven principal:
|
||||
//
|
||||
// 1. The caller signs a canonical challenge with their Substrate wallet.
|
||||
// 2. We verify that signature server-side (sr25519/ed25519) -> proves control
|
||||
// of `signerAddress`.
|
||||
// 3. We prove `signerAddress` OWNS the claimed identity (citizen number / visa):
|
||||
// - citizen -> People Chain `tiki.citizenNft(signerAddress)` + the
|
||||
// deterministic 6-digit derivation must match the claimed number.
|
||||
// - visa -> `p2p_visa` row (wallet_address == signerAddress, active).
|
||||
// 4. Only then do we derive `userId = identityToUUID(identityId)` SERVER-SIDE.
|
||||
//
|
||||
// The client-supplied `userId` is NEVER trusted for authorization. Every
|
||||
// caller can only ever act on the balance of the identity their wallet owns.
|
||||
|
||||
import { signatureVerify, cryptoWaitReady } from 'npm:@pezkuwi/util-crypto@14.0.25'
|
||||
import { stringToU8a, u8aWrapBytes } from 'npm:@pezkuwi/util@14.0.25'
|
||||
import type { ApiPromise } from 'npm:@pezkuwi/api@16.5.36'
|
||||
import type { SupabaseClient } from 'npm:@supabase/supabase-js@2'
|
||||
|
||||
// Max age of a signed challenge (replay window). Nonce dedup (see caller)
|
||||
// provides exact-once semantics; this bounds how long a captured signature
|
||||
// could even be presented.
|
||||
export const CHALLENGE_MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
// UUID v5 namespace (RFC 4122 DNS namespace) — MUST match shared/lib/identity.ts
|
||||
const UUID_V5_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'
|
||||
|
||||
/**
|
||||
* Deterministic UUID v5 from a citizen/visa number. Identical algorithm to
|
||||
* shared/lib/identity.ts (client) and verify-deposit — the value MUST match so
|
||||
* balances resolve to the same row everywhere.
|
||||
*/
|
||||
export async function identityToUUID(identityId: string): Promise<string> {
|
||||
const namespaceHex = UUID_V5_NAMESPACE.replace(/-/g, '')
|
||||
const namespaceBytes = new Uint8Array(16)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
namespaceBytes[i] = parseInt(namespaceHex.substr(i * 2, 2), 16)
|
||||
}
|
||||
const nameBytes = new TextEncoder().encode(identityId)
|
||||
const combined = new Uint8Array(namespaceBytes.length + nameBytes.length)
|
||||
combined.set(namespaceBytes)
|
||||
combined.set(nameBytes, namespaceBytes.length)
|
||||
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-1', combined)
|
||||
const h = new Uint8Array(hashBuffer)
|
||||
h[6] = (h[6] & 0x0f) | 0x50
|
||||
h[8] = (h[8] & 0x3f) | 0x80
|
||||
const hex = Array.from(h.slice(0, 16)).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic 6-digit citizen number derivation. Identical algorithm to
|
||||
* shared/lib/tiki.ts generateCitizenNumber — used to bind a citizen number to
|
||||
* the wallet that owns the NFT.
|
||||
*/
|
||||
export function generateCitizenNumber(ownerAddress: string, collectionId: number, itemId: number): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < ownerAddress.length; i++) {
|
||||
hash = ((hash << 5) - hash) + ownerAddress.charCodeAt(i)
|
||||
hash = hash & hash
|
||||
}
|
||||
hash += collectionId * 1000 + itemId
|
||||
hash = Math.abs(hash)
|
||||
return (hash % 1000000).toString().padStart(6, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a wallet signature over `message`. Handles both the raw-bytes form and
|
||||
* the `<Bytes>...</Bytes>` wrapped form that Polkadot-style extensions apply to
|
||||
* signRaw payloads, so extension, WalletConnect and native signers all verify.
|
||||
*/
|
||||
export async function verifyWalletSignature(
|
||||
message: string,
|
||||
signature: string,
|
||||
signerAddress: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await cryptoWaitReady()
|
||||
const raw = stringToU8a(message)
|
||||
const wrapped = u8aWrapBytes(message)
|
||||
for (const candidate of [raw, wrapped]) {
|
||||
const res = signatureVerify(candidate, signature, signerAddress)
|
||||
if (res.isValid) return true
|
||||
}
|
||||
return false
|
||||
} catch (_e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical challenge string for a withdrawal. MUST be byte-identical to what
|
||||
* the client builds and signs (see shared/lib/p2p-fiat.ts buildWithdrawChallenge).
|
||||
*/
|
||||
export function buildWithdrawChallenge(p: {
|
||||
identityId: string
|
||||
token: string
|
||||
amount: number
|
||||
destination: string
|
||||
signerAddress: string
|
||||
timestamp: number
|
||||
nonce: string
|
||||
}): string {
|
||||
return [
|
||||
'Pezkuwi P2P Withdrawal',
|
||||
`identity:${p.identityId}`,
|
||||
`token:${p.token}`,
|
||||
`amount:${p.amount}`,
|
||||
`destination:${p.destination}`,
|
||||
`signer:${p.signerAddress}`,
|
||||
`timestamp:${p.timestamp}`,
|
||||
`nonce:${p.nonce}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical challenge string for a deposit-credit. Binds the on-chain tx and the
|
||||
* identity being credited to the wallet that signs.
|
||||
*/
|
||||
export function buildDepositChallenge(p: {
|
||||
identityId: string
|
||||
token: string
|
||||
txHash: string
|
||||
signerAddress: string
|
||||
timestamp: number
|
||||
nonce: string
|
||||
}): string {
|
||||
return [
|
||||
'Pezkuwi P2P Deposit',
|
||||
`identity:${p.identityId}`,
|
||||
`token:${p.token}`,
|
||||
`tx:${p.txHash}`,
|
||||
`signer:${p.signerAddress}`,
|
||||
`timestamp:${p.timestamp}`,
|
||||
`nonce:${p.nonce}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical challenge string for a privileged admin action (dispute claim /
|
||||
* resolve). MUST be byte-identical to the client (DisputeResolutionPanel).
|
||||
*/
|
||||
export function buildAdminChallenge(p: {
|
||||
action: string
|
||||
disputeId: string
|
||||
tradeId: string
|
||||
decision: string
|
||||
adminAddress: string
|
||||
timestamp: number
|
||||
nonce: string
|
||||
}): string {
|
||||
return [
|
||||
'Pezkuwi P2P Admin Action',
|
||||
`action:${p.action}`,
|
||||
`dispute:${p.disputeId}`,
|
||||
`trade:${p.tradeId}`,
|
||||
`decision:${p.decision}`,
|
||||
`admin:${p.adminAddress}`,
|
||||
`timestamp:${p.timestamp}`,
|
||||
`nonce:${p.nonce}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export interface OwnershipResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
/** 'citizen' | 'visa' */
|
||||
kind?: 'citizen' | 'visa'
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove that `signerAddress` owns `identityId`.
|
||||
*
|
||||
* - Visa numbers ("V-XXXXXX"): must have an active p2p_visa row bound to the
|
||||
* signer's wallet_address.
|
||||
* - Citizen numbers ("#42-<item>-<6digit>"): the People Chain must report that
|
||||
* signerAddress owns the citizen NFT with that item id, and the deterministic
|
||||
* 6-digit derivation for (signerAddress, 42, itemId) must equal the claimed
|
||||
* 6-digit — i.e. the number is cryptographically tied to the wallet.
|
||||
*
|
||||
* `peopleApi` may be lazily created by the caller; it is only required for
|
||||
* citizen identities.
|
||||
*/
|
||||
export async function assertIdentityOwnedByWallet(
|
||||
serviceClient: SupabaseClient,
|
||||
identityId: string,
|
||||
signerAddress: string,
|
||||
getPeopleApi: () => Promise<ApiPromise>
|
||||
): Promise<OwnershipResult> {
|
||||
if (!identityId || !signerAddress) {
|
||||
return { ok: false, error: 'Missing identity or signer' }
|
||||
}
|
||||
|
||||
// ---- Visa identity ----
|
||||
if (identityId.startsWith('V-')) {
|
||||
const { data: visa, error } = await serviceClient
|
||||
.from('p2p_visa')
|
||||
.select('visa_number, wallet_address, status')
|
||||
.eq('visa_number', identityId)
|
||||
.eq('wallet_address', signerAddress)
|
||||
.eq('status', 'active')
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return { ok: false, error: 'Visa lookup failed' }
|
||||
if (!visa) return { ok: false, error: 'Signing wallet does not own this visa identity' }
|
||||
return { ok: true, kind: 'visa' }
|
||||
}
|
||||
|
||||
// ---- Citizen identity: "#<collection>-<item>-<6digit>" ----
|
||||
const clean = identityId.trim().replace('#', '')
|
||||
const parts = clean.split('-')
|
||||
if (parts.length !== 3) {
|
||||
return { ok: false, error: 'Invalid citizen identity format' }
|
||||
}
|
||||
const collectionId = parseInt(parts[0], 10)
|
||||
const itemId = parseInt(parts[1], 10)
|
||||
const providedSixDigit = parts[2]
|
||||
if (isNaN(collectionId) || isNaN(itemId) || providedSixDigit.length !== 6) {
|
||||
return { ok: false, error: 'Invalid citizen identity format' }
|
||||
}
|
||||
if (collectionId !== 42) {
|
||||
return { ok: false, error: 'Invalid citizen collection' }
|
||||
}
|
||||
|
||||
let peopleApi: ApiPromise
|
||||
try {
|
||||
peopleApi = await getPeopleApi()
|
||||
} catch (_e) {
|
||||
return { ok: false, error: 'People Chain unavailable for identity verification' }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!peopleApi.query?.tiki?.citizenNft) {
|
||||
return { ok: false, error: 'Citizen NFT pallet unavailable' }
|
||||
}
|
||||
const res: any = await peopleApi.query.tiki.citizenNft(signerAddress)
|
||||
if (res.isEmpty || (res.isSome === false && typeof res.isSome === 'boolean')) {
|
||||
return { ok: false, error: 'Signing wallet owns no citizen NFT' }
|
||||
}
|
||||
const actualItemId = res.isSome ? res.unwrap().toNumber() : (typeof res.toNumber === 'function' ? res.toNumber() : null)
|
||||
if (actualItemId === null || actualItemId !== itemId) {
|
||||
return { ok: false, error: 'Citizen NFT does not match signing wallet' }
|
||||
}
|
||||
const expected = generateCitizenNumber(signerAddress, collectionId, itemId)
|
||||
if (expected !== providedSixDigit) {
|
||||
return { ok: false, error: 'Citizen number does not match signing wallet' }
|
||||
}
|
||||
return { ok: true, kind: 'citizen' }
|
||||
} catch (_e) {
|
||||
return { ok: false, error: 'Citizen identity verification failed' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically record a used challenge nonce for replay protection. Returns false
|
||||
* if the nonce was already used (replay) — caller MUST reject in that case.
|
||||
* Requires table public.p2p_challenge_nonces(nonce text primary key, ...).
|
||||
*/
|
||||
export async function consumeNonce(
|
||||
serviceClient: SupabaseClient,
|
||||
nonce: string,
|
||||
purpose: string
|
||||
): Promise<boolean> {
|
||||
if (!nonce || nonce.length < 8) return false
|
||||
const { error } = await serviceClient
|
||||
.from('p2p_challenge_nonces')
|
||||
.insert({ nonce, purpose })
|
||||
// Unique violation => already used => replay.
|
||||
if (error) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Basic timestamp freshness check for a signed challenge. */
|
||||
export function isFreshTimestamp(timestamp: number): boolean {
|
||||
if (!Number.isFinite(timestamp)) return false
|
||||
const age = Date.now() - timestamp
|
||||
return age >= -60_000 && age <= CHALLENGE_MAX_AGE_MS // allow 60s clock skew
|
||||
}
|
||||
@@ -6,6 +6,14 @@ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
import { ApiPromise, WsProvider, Keyring } from 'npm:@pezkuwi/api@16.5.36'
|
||||
import { cryptoWaitReady } from 'npm:@pezkuwi/util-crypto@14.0.25'
|
||||
import {
|
||||
identityToUUID,
|
||||
verifyWalletSignature,
|
||||
buildWithdrawChallenge,
|
||||
assertIdentityOwnedByWallet,
|
||||
consumeNonce,
|
||||
isFreshTimestamp,
|
||||
} from '../_shared/identity-auth.ts'
|
||||
|
||||
// Allowed origins for CORS
|
||||
const ALLOWED_ORIGINS = [
|
||||
@@ -35,6 +43,9 @@ const DECIMALS = 12
|
||||
// PEZ asset ID
|
||||
const PEZ_ASSET_ID = 1
|
||||
|
||||
// People Chain endpoint — required to verify citizen NFT ownership server-side
|
||||
const PEOPLE_RPC_ENDPOINT = Deno.env.get('PEOPLE_RPC_ENDPOINT') || 'wss://people-rpc.pezkuwichain.io'
|
||||
|
||||
// Minimum withdrawal amounts
|
||||
const MIN_WITHDRAW = {
|
||||
HEZ: 1,
|
||||
@@ -49,10 +60,19 @@ const WITHDRAW_FEE = {
|
||||
|
||||
interface WithdrawRequest {
|
||||
requestId?: string // If processing specific request
|
||||
userId: string // Identity-based UUID (from citizen/visa number)
|
||||
|
||||
// ---- Authorization (all required) ----
|
||||
// The caller proves control of a wallet that OWNS `identityId`. The user_id
|
||||
// is derived server-side from identityId; a client-supplied user_id is IGNORED.
|
||||
identityId?: string // Citizen number (#42-<item>-<6d>) or visa (V-XXXXXX)
|
||||
signerAddress?: string // Wallet that signed the challenge
|
||||
signature?: string // Signature over the canonical withdraw challenge
|
||||
timestamp?: number // ms epoch, must be fresh
|
||||
nonce?: string // single-use, replay-protected
|
||||
|
||||
token?: 'HEZ' | 'PEZ'
|
||||
amount?: number
|
||||
walletAddress?: string
|
||||
walletAddress?: string // Withdrawal destination (bound into the signature)
|
||||
}
|
||||
|
||||
// Cache API connection
|
||||
@@ -68,6 +88,18 @@ async function getApi(): Promise<ApiPromise> {
|
||||
return apiInstance
|
||||
}
|
||||
|
||||
// Cache People Chain connection (citizen NFT ownership verification)
|
||||
let peopleApiInstance: ApiPromise | null = null
|
||||
|
||||
async function getPeopleApi(): Promise<ApiPromise> {
|
||||
if (peopleApiInstance && peopleApiInstance.isConnected) {
|
||||
return peopleApiInstance
|
||||
}
|
||||
const provider = new WsProvider(PEOPLE_RPC_ENDPOINT)
|
||||
peopleApiInstance = await ApiPromise.create({ provider })
|
||||
return peopleApiInstance
|
||||
}
|
||||
|
||||
// Send tokens from hot wallet
|
||||
async function sendTokens(
|
||||
api: ApiPromise,
|
||||
@@ -190,7 +222,8 @@ serve(async (req) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get authorization header
|
||||
// Get authorization header (transport-level only — NOT the authz boundary).
|
||||
// Real authorization is the wallet signature + identity-ownership proof below.
|
||||
const authHeader = req.headers.get('Authorization')
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
@@ -218,16 +251,74 @@ serve(async (req) => {
|
||||
|
||||
// Parse request body
|
||||
const body: WithdrawRequest = await req.json()
|
||||
const { userId } = body
|
||||
const { identityId, signerAddress, signature, timestamp, nonce } = body
|
||||
let { requestId, token, amount, walletAddress } = body
|
||||
|
||||
if (!userId) {
|
||||
// =====================================================
|
||||
// OBJECT-LEVEL AUTHORIZATION (the security boundary)
|
||||
// =====================================================
|
||||
// 1) Require a complete signed challenge.
|
||||
if (!identityId || !signerAddress || !signature || !nonce || typeof timestamp !== 'number') {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'Missing required field: userId' }),
|
||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
JSON.stringify({ success: false, error: 'Missing authorization (identityId, signerAddress, signature, timestamp, nonce required)' }),
|
||||
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// 2) The signature covers the full withdrawal intent, incl. destination and
|
||||
// amount. Determine the values that will be bound into the challenge.
|
||||
// - Mode 1 (requestId): destination/amount/token come from the stored
|
||||
// request; the client still signs them (fetched before submit).
|
||||
// - Mode 2: destination/amount/token come from the (signed) body.
|
||||
if (!isFreshTimestamp(timestamp)) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'Authorization challenge expired. Please retry.' }),
|
||||
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// 3) Verify the wallet signature over the canonical challenge. For Mode 1 the
|
||||
// signed destination/amount/token are supplied alongside requestId and are
|
||||
// re-checked against the stored request after lookup.
|
||||
const challenge = buildWithdrawChallenge({
|
||||
identityId,
|
||||
token: String(token ?? ''),
|
||||
amount: Number(amount ?? 0),
|
||||
destination: String(walletAddress ?? ''),
|
||||
signerAddress,
|
||||
timestamp,
|
||||
nonce,
|
||||
})
|
||||
const sigOk = await verifyWalletSignature(challenge, signature, signerAddress)
|
||||
if (!sigOk) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'Invalid signature' }),
|
||||
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// 4) Prove the signing wallet OWNS the claimed identity (citizen NFT / visa).
|
||||
const ownership = await assertIdentityOwnedByWallet(serviceClient, identityId, signerAddress, getPeopleApi)
|
||||
if (!ownership.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: ownership.error || 'Identity ownership verification failed' }),
|
||||
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// 5) Consume the nonce (single-use) to block replay of a captured signature.
|
||||
const nonceOk = await consumeNonce(serviceClient, nonce, 'withdraw')
|
||||
if (!nonceOk) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'Authorization already used (replay detected)' }),
|
||||
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// 6) Derive user_id SERVER-SIDE from the verified identity. Any client-supplied
|
||||
// user_id is intentionally ignored.
|
||||
const userId = await identityToUUID(identityId)
|
||||
|
||||
// Mode 1: Process existing request by ID
|
||||
if (requestId) {
|
||||
const { data: request, error: reqError } = await serviceClient
|
||||
@@ -246,8 +337,23 @@ serve(async (req) => {
|
||||
)
|
||||
}
|
||||
|
||||
// The signed challenge (built from body values) must match the stored
|
||||
// request, so the signature authorizes exactly this withdrawal.
|
||||
const storedAmount = parseFloat(request.amount)
|
||||
const bodyAmount = Number(amount ?? 0)
|
||||
if (
|
||||
String(token ?? '') !== String(request.token) ||
|
||||
String(walletAddress ?? '') !== String(request.wallet_address) ||
|
||||
Math.abs(bodyAmount - storedAmount) > 1e-9
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: 'Signed withdrawal does not match the stored request' }),
|
||||
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
token = request.token as 'HEZ' | 'PEZ'
|
||||
amount = parseFloat(request.amount)
|
||||
amount = storedAmount
|
||||
walletAddress = request.wallet_address
|
||||
}
|
||||
// Mode 2: Create new withdrawal request
|
||||
|
||||
@@ -199,11 +199,16 @@ serve(async (req: Request) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify authorization (should be called with service role key or admin JWT)
|
||||
// Authorization: this batch processor drains ALL pending withdrawal requests
|
||||
// to their destination wallets, so it MUST be restricted to the backend
|
||||
// service role (cron / admin tooling). Merely having *any* bearer token
|
||||
// (e.g. the public anon key) is NOT sufficient — previously that let anyone
|
||||
// trigger mass processing.
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
const bearer = authHeader?.replace(/^Bearer\s+/i, "").trim();
|
||||
if (!bearer || bearer !== SUPABASE_SERVICE_ROLE_KEY) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unauthorized" }),
|
||||
JSON.stringify({ error: "Unauthorized: service role required" }),
|
||||
{ status: 401, headers }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// resolve-dispute Edge Function
|
||||
//
|
||||
// Server-side authorization for privileged P2P dispute actions (claim / resolve).
|
||||
// The admin authorization is NOT the client isAdmin flag (which is cosmetic and
|
||||
// bypassable). Here we:
|
||||
// 1. Verify a wallet signature over a canonical admin-action challenge.
|
||||
// 2. Check the signing wallet is in the server-side admin wallet set.
|
||||
// 3. Enforce freshness + single-use nonce (replay protection).
|
||||
// 4. For 'resolve', invoke admin_resolve_dispute() with the service role so the
|
||||
// escrow movement + status changes happen atomically server-side.
|
||||
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
import {
|
||||
verifyWalletSignature,
|
||||
buildAdminChallenge,
|
||||
consumeNonce,
|
||||
isFreshTimestamp,
|
||||
} from '../_shared/identity-auth.ts'
|
||||
|
||||
const ALLOWED_ORIGINS = [
|
||||
'https://app.pezkuwichain.io',
|
||||
'https://www.pezkuwichain.io',
|
||||
'https://pezkuwichain.io',
|
||||
]
|
||||
|
||||
function getCorsHeaders(origin: string | null) {
|
||||
const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]
|
||||
return {
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
}
|
||||
}
|
||||
|
||||
// Authoritative admin wallet set (server-side). Overridable via ADMIN_WALLETS
|
||||
// (comma-separated SS58). Defaults mirror the historical client whitelist.
|
||||
function getAdminWallets(): string[] {
|
||||
const env = Deno.env.get('ADMIN_WALLETS')
|
||||
if (env) return env.split(',').map(s => s.trim()).filter(Boolean)
|
||||
return [
|
||||
'5CyuFfbF95rzBxru7c9yEsX4XmQXUxpLUcbj9RLg9K1cGiiF', // Founder
|
||||
'5EhCpn82QtdU53MF6PoNFrKHgSrsfcAxFTMwrn3JYf9dioQw', // Treasury admin
|
||||
'5ELgySrX5ZyK7EWXjj6bAedyTCcTNWDANbiiipsT5gnpoCEp', // Admin
|
||||
]
|
||||
}
|
||||
|
||||
interface Body {
|
||||
action?: 'claim' | 'resolve'
|
||||
disputeId?: string
|
||||
tradeId?: string
|
||||
decision?: string
|
||||
reasoning?: string
|
||||
adminAddress?: string
|
||||
signature?: string
|
||||
timestamp?: number
|
||||
nonce?: string
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
const corsHeaders = getCorsHeaders(req.headers.get('Origin'))
|
||||
const json = (status: number, obj: unknown) =>
|
||||
new Response(JSON.stringify(obj), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
const serviceClient = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
const body: Body = await req.json()
|
||||
const { action, disputeId, tradeId, decision, reasoning, adminAddress, signature, timestamp, nonce } = body
|
||||
|
||||
if (!action || !['claim', 'resolve'].includes(action)) {
|
||||
return json(400, { success: false, error: 'Invalid action' })
|
||||
}
|
||||
if (!disputeId || !tradeId || !adminAddress || !signature || !nonce || typeof timestamp !== 'number') {
|
||||
return json(401, { success: false, error: 'Missing authorization fields' })
|
||||
}
|
||||
if (action === 'resolve' && (!decision || !reasoning)) {
|
||||
return json(400, { success: false, error: 'Decision and reasoning are required' })
|
||||
}
|
||||
|
||||
// 1) Freshness
|
||||
if (!isFreshTimestamp(timestamp)) {
|
||||
return json(401, { success: false, error: 'Authorization challenge expired. Please retry.' })
|
||||
}
|
||||
|
||||
// 2) Verify signature over the canonical admin challenge
|
||||
const challenge = buildAdminChallenge({
|
||||
action,
|
||||
disputeId,
|
||||
tradeId,
|
||||
decision: String(decision ?? ''),
|
||||
adminAddress,
|
||||
timestamp,
|
||||
nonce,
|
||||
})
|
||||
const sigOk = await verifyWalletSignature(challenge, signature, adminAddress)
|
||||
if (!sigOk) {
|
||||
return json(401, { success: false, error: 'Invalid signature' })
|
||||
}
|
||||
|
||||
// 3) Server-side admin authorization
|
||||
if (!getAdminWallets().includes(adminAddress)) {
|
||||
return json(403, { success: false, error: 'Wallet is not authorized for admin actions' })
|
||||
}
|
||||
|
||||
// 4) Replay protection
|
||||
const nonceOk = await consumeNonce(serviceClient, nonce, `admin_${action}`)
|
||||
if (!nonceOk) {
|
||||
return json(401, { success: false, error: 'Authorization already used (replay detected)' })
|
||||
}
|
||||
|
||||
// ---- CLAIM ----
|
||||
if (action === 'claim') {
|
||||
const { error } = await serviceClient
|
||||
.from('p2p_fiat_disputes')
|
||||
.update({ status: 'under_review', assigned_at: new Date().toISOString() })
|
||||
.eq('id', disputeId)
|
||||
if (error) return json(500, { success: false, error: 'Failed to claim dispute' })
|
||||
return json(200, { success: true, action: 'claim' })
|
||||
}
|
||||
|
||||
// ---- RESOLVE (atomic escrow movement + status) ----
|
||||
const { data, error } = await serviceClient.rpc('admin_resolve_dispute', {
|
||||
p_dispute_id: disputeId,
|
||||
p_trade_id: tradeId,
|
||||
p_decision: decision,
|
||||
p_reasoning: reasoning,
|
||||
p_admin_ref: adminAddress,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('admin_resolve_dispute error:', error)
|
||||
return json(500, { success: false, error: error.message || 'Resolution failed' })
|
||||
}
|
||||
const result = typeof data === 'string' ? JSON.parse(data) : data
|
||||
if (!result?.success) {
|
||||
return json(400, { success: false, error: result?.error || 'Resolution failed' })
|
||||
}
|
||||
|
||||
return json(200, { success: true, ...result })
|
||||
} catch (error) {
|
||||
console.error('resolve-dispute error:', error)
|
||||
return json(500, { success: false, error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
import { ApiPromise, WsProvider } from 'npm:@pezkuwi/api@16.5.36'
|
||||
import { blake2b } from 'npm:@noble/hashes@1.7.1/blake2b'
|
||||
import { base58 } from 'npm:@scure/base@1.2.4'
|
||||
import { assertIdentityOwnedByWallet } from '../_shared/identity-auth.ts'
|
||||
|
||||
// Allowed origins for CORS
|
||||
const ALLOWED_ORIGINS = [
|
||||
@@ -61,6 +62,17 @@ async function identityToUUID(identityId: string): Promise<string> {
|
||||
// PEZ asset ID
|
||||
const PEZ_ASSET_ID = 1
|
||||
|
||||
// People Chain endpoint — required to verify citizen NFT ownership server-side
|
||||
const PEOPLE_RPC_ENDPOINT = Deno.env.get('PEOPLE_RPC_ENDPOINT') || 'wss://people-rpc.pezkuwichain.io'
|
||||
|
||||
let peopleApiInstance: ApiPromise | null = null
|
||||
async function getPeopleApi(): Promise<ApiPromise> {
|
||||
if (peopleApiInstance && peopleApiInstance.isConnected) return peopleApiInstance
|
||||
const provider = new WsProvider(PEOPLE_RPC_ENDPOINT)
|
||||
peopleApiInstance = await ApiPromise.create({ provider })
|
||||
return peopleApiInstance
|
||||
}
|
||||
|
||||
interface DepositRequest {
|
||||
txHash: string
|
||||
token: 'HEZ' | 'PEZ'
|
||||
@@ -501,6 +513,30 @@ serve(async (req) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Bind the crediting identity to the depositing wallet. The on-chain sender
|
||||
// has just been proven to equal `walletAddress`; requiring that wallet to
|
||||
// OWN `identityId` prevents crediting a balance the depositor does not own
|
||||
// (citizen NFT / active visa binding).
|
||||
const depositOwnership = await assertIdentityOwnedByWallet(serviceClient, identityId, walletAddress, getPeopleApi)
|
||||
if (!depositOwnership.ok) {
|
||||
await serviceClient
|
||||
.from('p2p_deposit_withdraw_requests')
|
||||
.update({
|
||||
status: 'failed',
|
||||
error_message: `Identity ownership check failed: ${depositOwnership.error}`,
|
||||
processed_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', depositRequest.id)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: depositOwnership.error || 'Depositing wallet does not own this identity'
|
||||
}),
|
||||
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// Process deposit
|
||||
const { data: processResult, error: processError } = await serviceClient
|
||||
.rpc('process_deposit', {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
-- =====================================================
|
||||
-- Migration: Withdrawal authorization hardening
|
||||
-- Date: 2026-07-25
|
||||
-- =====================================================
|
||||
--
|
||||
-- Part of the fix for the CRITICAL broken-object-level-auth finding on the
|
||||
-- withdrawal path. Two defenses:
|
||||
--
|
||||
-- 1. Replay protection for the signed withdrawal challenge (nonce store).
|
||||
-- 2. Defense-in-depth: request_withdraw() may ONLY be executed by the backend
|
||||
-- service role. Previously it was executable by anon/authenticated, so an
|
||||
-- attacker with the public anon key could call it directly with a victim's
|
||||
-- user_id to create a pending withdrawal to an attacker-controlled wallet,
|
||||
-- which the batch processor would then pay out.
|
||||
--
|
||||
|
||||
-- =====================================================
|
||||
-- 1. CHALLENGE NONCE STORE (single-use signed challenges)
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS public.p2p_challenge_nonces (
|
||||
nonce TEXT PRIMARY KEY,
|
||||
purpose TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_p2p_challenge_nonces_created
|
||||
ON public.p2p_challenge_nonces(created_at);
|
||||
|
||||
ALTER TABLE public.p2p_challenge_nonces ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Only the service role (edge functions) may read/write nonces.
|
||||
DROP POLICY IF EXISTS "challenge_nonces_service_only" ON public.p2p_challenge_nonces;
|
||||
CREATE POLICY "challenge_nonces_service_only" ON public.p2p_challenge_nonces
|
||||
FOR ALL USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
|
||||
|
||||
REVOKE ALL ON public.p2p_challenge_nonces FROM anon, authenticated, PUBLIC;
|
||||
|
||||
-- =====================================================
|
||||
-- 2. LOCK request_withdraw() TO SERVICE ROLE
|
||||
-- =====================================================
|
||||
-- Recreate with an explicit service-role guard (mirrors process_deposit()).
|
||||
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
|
||||
-- SECURITY: backend service role only. Object-level authorization (proving the
|
||||
-- caller owns p_user_id) is enforced by the process-withdraw edge function via
|
||||
-- a verified wallet signature before this is ever invoked.
|
||||
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: withdrawals must go through the backend service'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- 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;
|
||||
|
||||
REVOKE EXECUTE ON FUNCTION request_withdraw(UUID, TEXT, DECIMAL, TEXT) FROM PUBLIC, anon, authenticated;
|
||||
|
||||
COMMENT ON FUNCTION request_withdraw IS
|
||||
'Create withdrawal request and lock balance. SERVICE ROLE ONLY — called by the
|
||||
process-withdraw edge function after it verifies the caller owns the identity via
|
||||
a wallet signature. Never call directly from the client.';
|
||||
@@ -0,0 +1,122 @@
|
||||
-- =====================================================
|
||||
-- Migration: Harden RLS on financial + PII tables
|
||||
-- Date: 2026-07-25
|
||||
-- =====================================================
|
||||
--
|
||||
-- Forward migration (does NOT rewrite history) that removes the blanket
|
||||
-- `USING (true)` read policies added in 20260223160000_fix_rls_for_wallet_auth
|
||||
-- on the crown-jewel financial/PII tables, so the public anon key can no longer
|
||||
-- enumerate every user's balances, ledger history, deposit/withdraw requests, or
|
||||
-- payment-method PII.
|
||||
--
|
||||
-- Reads the app legitimately needs are moved behind SECURITY DEFINER RPCs that
|
||||
-- return only the requested user's rows (instead of the whole table). Writes to
|
||||
-- these tables already go through service_role edge functions / SECURITY DEFINER
|
||||
-- RPCs, so no client write path is affected.
|
||||
--
|
||||
-- NOTE (residual, tracked): user_id is a UUID v5 derived from a citizen/visa
|
||||
-- number, which is not a secret. These RPCs remove full-table exfiltration but do
|
||||
-- not yet cryptographically bind the reader to the row owner. Fully closing that
|
||||
-- requires a signed-session read path (same mechanism as withdrawals) applied to
|
||||
-- these reads — see the security report. The high-severity leak (dump ALL rows)
|
||||
-- is closed here.
|
||||
|
||||
-- =====================================================
|
||||
-- 1. user_internal_balances — no direct client reads (uses
|
||||
-- get_user_internal_balance RPC which is SECURITY DEFINER and bypasses RLS)
|
||||
-- =====================================================
|
||||
DROP POLICY IF EXISTS "balances_anon_select" ON public.user_internal_balances;
|
||||
-- No anon policy remains -> anon/authenticated cannot read the table directly.
|
||||
-- SECURITY DEFINER RPCs (get_user_internal_balance) continue to work.
|
||||
|
||||
-- =====================================================
|
||||
-- 2. p2p_balance_transactions — replace open SELECT with a scoped RPC
|
||||
-- =====================================================
|
||||
DROP POLICY IF EXISTS "balance_tx_anon_select" ON public.p2p_balance_transactions;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_user_balance_transactions(
|
||||
p_user_id UUID,
|
||||
p_limit INT DEFAULT 50
|
||||
) RETURNS SETOF public.p2p_balance_transactions AS $$
|
||||
SELECT *
|
||||
FROM public.p2p_balance_transactions
|
||||
WHERE user_id = p_user_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT LEAST(GREATEST(COALESCE(p_limit, 50), 1), 200);
|
||||
$$ LANGUAGE sql STABLE SECURITY DEFINER;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_user_balance_transactions(UUID, INT) TO anon, authenticated;
|
||||
|
||||
COMMENT ON FUNCTION public.get_user_balance_transactions IS
|
||||
'Returns balance-transaction history for a single user_id only (no full-table
|
||||
dump). Replaces the removed USING(true) SELECT policy.';
|
||||
|
||||
-- =====================================================
|
||||
-- 3. p2p_deposit_withdraw_requests — replace open SELECT with a scoped RPC
|
||||
-- (service_role write policy from 20260223160000 is retained)
|
||||
-- =====================================================
|
||||
DROP POLICY IF EXISTS "deposit_requests_anon_select" ON public.p2p_deposit_withdraw_requests;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_user_deposit_withdraw_requests(
|
||||
p_user_id UUID,
|
||||
p_limit INT DEFAULT 50
|
||||
) RETURNS SETOF public.p2p_deposit_withdraw_requests AS $$
|
||||
SELECT *
|
||||
FROM public.p2p_deposit_withdraw_requests
|
||||
WHERE user_id = p_user_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT LEAST(GREATEST(COALESCE(p_limit, 50), 1), 200);
|
||||
$$ LANGUAGE sql STABLE SECURITY DEFINER;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_user_deposit_withdraw_requests(UUID, INT) TO anon, authenticated;
|
||||
|
||||
COMMENT ON FUNCTION public.get_user_deposit_withdraw_requests IS
|
||||
'Returns deposit/withdraw requests for a single user_id only. Replaces the
|
||||
removed USING(true) SELECT policy.';
|
||||
|
||||
-- =====================================================
|
||||
-- 4. p2p_user_payment_methods (IBAN / payment PII) — lock to service role
|
||||
-- (no direct client access exists; offers embed encrypted payment details).
|
||||
-- =====================================================
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'p2p_user_payment_methods') THEN
|
||||
EXECUTE 'ALTER TABLE public.p2p_user_payment_methods ENABLE ROW LEVEL SECURITY';
|
||||
EXECUTE 'DROP POLICY IF EXISTS "user_pm_anon_select" ON public.p2p_user_payment_methods';
|
||||
EXECUTE 'DROP POLICY IF EXISTS "user_pm_anon_insert" ON public.p2p_user_payment_methods';
|
||||
EXECUTE 'DROP POLICY IF EXISTS "user_pm_anon_update" ON public.p2p_user_payment_methods';
|
||||
EXECUTE 'DROP POLICY IF EXISTS "user_pm_service_only" ON public.p2p_user_payment_methods';
|
||||
EXECUTE 'CREATE POLICY "user_pm_service_only" ON public.p2p_user_payment_methods
|
||||
FOR ALL USING (auth.role() = ''service_role'') WITH CHECK (auth.role() = ''service_role'')';
|
||||
EXECUTE 'REVOKE ALL ON public.p2p_user_payment_methods FROM anon, authenticated, PUBLIC';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- 5. p2p_fiat_disputes — remove world-open UPDATE.
|
||||
-- Dispute state transitions (claim/resolve) now go through the service-role
|
||||
-- `resolve-dispute` edge function (which verifies the admin wallet signature).
|
||||
-- Regular users still INSERT disputes (disputes_anon_insert retained).
|
||||
-- =====================================================
|
||||
DROP POLICY IF EXISTS "disputes_anon_update" ON public.p2p_fiat_disputes;
|
||||
DROP POLICY IF EXISTS "disputes_service_update" ON public.p2p_fiat_disputes;
|
||||
CREATE POLICY "disputes_service_update" ON public.p2p_fiat_disputes
|
||||
FOR UPDATE USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
|
||||
|
||||
-- =====================================================
|
||||
-- Done. Balances, ledger, deposit/withdraw requests and payment PII are no longer
|
||||
-- world-readable via the anon key; dispute resolution is no longer world-writable.
|
||||
--
|
||||
-- RESIDUAL (documented in the security report, NOT closed here to avoid breaking
|
||||
-- the live app without the signed-session refactor):
|
||||
-- * p2p_fiat_offers / p2p_fiat_trades / p2p_messages still carry USING(true)
|
||||
-- policies because the client reads/writes them directly with the anon key
|
||||
-- and there is no server session to bind to. Marking a trade status does not
|
||||
-- itself move funds.
|
||||
-- * The internal-ledger escrow RPCs (lock_/release_/refund_escrow_internal)
|
||||
-- are still EXECUTE-able by anon (confirmPaymentReceived / createFiatOffer
|
||||
-- call them directly). This is a SEPARATE critical hole that needs the same
|
||||
-- signed-challenge mechanism applied to trade actions (an authenticated
|
||||
-- confirm-payment / create-offer edge function). See report.
|
||||
-- =====================================================
|
||||
@@ -0,0 +1,158 @@
|
||||
-- =====================================================
|
||||
-- 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.';
|
||||
Reference in New Issue
Block a user