mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-26 08:35:41 +00:00
security(p2p): close escrow-RPC anon-drain (release/lock behind signed edge functions)
Round 2 — fixes the CRITICAL discovered during round 1 (as severe as the withdrawal BOLA): lock_/release_/refund_escrow_internal had default PUBLIC EXECUTE and the client called release_escrow_internal directly with the anon key, so anyone could drain any victim's LOCKED balance. - release_escrow_internal now reachable only via new signed confirm-payment edge function: verifies seller wallet signature (raw + <Bytes>), asserts the wallet owns the seller identity, derived user_id == trade.seller_id, trade == payment_sent, single-use nonce, then release runs with the service role. confirmPaymentReceived now invokes confirm-payment (no more anon rpc). - lock_escrow_internal now behind new signed lock-escrow edge function (a caller can only lock its own balance; stops griefing a victim's balance). Removed a zero-amount no-op lock call. - Migration 20260725030000: REVOKE EXECUTE on lock_/release_/refund_escrow_internal from PUBLIC/anon/authenticated; GRANT to service_role only. refund has no direct client caller (service-role + admin_resolve_dispute only). - DEPLOY_RUNBOOK.md consolidates the ordered migrations, edge functions and secrets for both security rounds. Migration 20260725030000 must ship WITH the two new edge functions + frontend or offer-create/payment-release break. Remaining (non-fund, Round 2+ follow-up): read RPCs still key on a non-secret user_id (binding to a signed session would force a sign-prompt on every passive balance read — deferred); p2p_fiat_offers/trades/messages retain USING(true) (status labels move no funds now that all escrow movement is service-role-gated).
This commit is contained in:
+124
-90
@@ -119,6 +119,8 @@ export interface P2PReputation {
|
||||
|
||||
export interface CreateOfferParams {
|
||||
userId: string;
|
||||
/** Citizen/visa identity id (needed to authorize the escrow lock) */
|
||||
identityId: string;
|
||||
sellerWallet: string;
|
||||
token: CryptoToken;
|
||||
amountCrypto: number;
|
||||
@@ -130,6 +132,8 @@ export interface CreateOfferParams {
|
||||
minOrderAmount?: number;
|
||||
maxOrderAmount?: number;
|
||||
adType?: 'buy' | 'sell';
|
||||
/** Signs a message with the wallet that owns `identityId` (signRaw) */
|
||||
signMessage: (message: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface AcceptOfferParams {
|
||||
@@ -375,9 +379,54 @@ async function decryptPaymentDetails(encrypted: string): Promise<Record<string,
|
||||
*
|
||||
* NOTE: Blockchain transactions only occur at deposit/withdraw
|
||||
*/
|
||||
/**
|
||||
* Canonical lock-escrow challenge. MUST be byte-identical to the server
|
||||
* buildLockEscrowChallenge (web/supabase/functions/_shared/identity-auth.ts).
|
||||
*/
|
||||
export function buildLockEscrowChallenge(p: {
|
||||
identityId: string;
|
||||
token: string;
|
||||
amount: number;
|
||||
signerAddress: string;
|
||||
timestamp: number;
|
||||
nonce: string;
|
||||
}): string {
|
||||
return [
|
||||
'Pezkuwi P2P Lock Escrow',
|
||||
`identity:${p.identityId}`,
|
||||
`token:${p.token}`,
|
||||
`amount:${p.amount}`,
|
||||
`signer:${p.signerAddress}`,
|
||||
`timestamp:${p.timestamp}`,
|
||||
`nonce:${p.nonce}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical confirm-payment challenge. MUST be byte-identical to the server
|
||||
* buildConfirmPaymentChallenge (web/supabase/functions/_shared/identity-auth.ts).
|
||||
*/
|
||||
export function buildConfirmPaymentChallenge(p: {
|
||||
tradeId: string;
|
||||
sellerIdentity: string;
|
||||
signerAddress: string;
|
||||
timestamp: number;
|
||||
nonce: string;
|
||||
}): string {
|
||||
return [
|
||||
'Pezkuwi P2P Confirm Payment',
|
||||
`trade:${p.tradeId}`,
|
||||
`identity:${p.sellerIdentity}`,
|
||||
`signer:${p.signerAddress}`,
|
||||
`timestamp:${p.timestamp}`,
|
||||
`nonce:${p.nonce}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function createFiatOffer(params: CreateOfferParams): Promise<string> {
|
||||
const {
|
||||
userId,
|
||||
identityId,
|
||||
sellerWallet,
|
||||
token,
|
||||
amountCrypto,
|
||||
@@ -388,19 +437,42 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
|
||||
timeLimitMinutes = DEFAULT_PAYMENT_DEADLINE_MINUTES,
|
||||
minOrderAmount,
|
||||
maxOrderAmount,
|
||||
adType = 'sell'
|
||||
adType = 'sell',
|
||||
signMessage
|
||||
} = params;
|
||||
|
||||
try {
|
||||
if (!userId) throw new Error('Identity required for P2P trading');
|
||||
if (!identityId) throw new Error('Identity required for P2P trading');
|
||||
if (!sellerWallet) throw new Error('Connect a wallet to create an offer');
|
||||
|
||||
toast.info('Locking crypto from your balance...');
|
||||
// 1. Lock crypto from internal balance via the authorized edge function.
|
||||
// lock_escrow_internal is no longer callable with the anon key; the
|
||||
// seller must sign a challenge proving they own the identity being locked.
|
||||
const lockTimestamp = Date.now();
|
||||
const lockNonce = `lk-${lockTimestamp}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
||||
const lockChallenge = buildLockEscrowChallenge({
|
||||
identityId,
|
||||
token,
|
||||
amount: amountCrypto,
|
||||
signerAddress: sellerWallet,
|
||||
timestamp: lockTimestamp,
|
||||
nonce: lockNonce,
|
||||
});
|
||||
|
||||
// 1. Lock crypto from internal balance (NO blockchain tx!)
|
||||
const { data: lockResult, error: lockError } = await supabase.rpc('lock_escrow_internal', {
|
||||
p_user_id: userId,
|
||||
p_token: token,
|
||||
p_amount: amountCrypto
|
||||
toast.info('Sign to lock crypto from your balance...');
|
||||
const lockSignature = await signMessage(lockChallenge);
|
||||
|
||||
const { data: lockResult, error: lockError } = await supabase.functions.invoke('lock-escrow', {
|
||||
body: {
|
||||
identityId,
|
||||
token,
|
||||
amount: amountCrypto,
|
||||
signerAddress: sellerWallet,
|
||||
signature: lockSignature,
|
||||
timestamp: lockTimestamp,
|
||||
nonce: lockNonce,
|
||||
},
|
||||
});
|
||||
|
||||
if (lockError) throw lockError;
|
||||
@@ -408,8 +480,8 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
|
||||
// Parse result
|
||||
const lockResponse = typeof lockResult === 'string' ? JSON.parse(lockResult) : lockResult;
|
||||
|
||||
if (!lockResponse.success) {
|
||||
throw new Error(lockResponse.error || 'Failed to lock balance');
|
||||
if (!lockResponse?.success) {
|
||||
throw new Error(lockResponse?.error || 'Failed to lock balance');
|
||||
}
|
||||
|
||||
toast.success('Balance locked successfully');
|
||||
@@ -443,18 +515,9 @@ export async function createFiatOffer(params: CreateOfferParams): Promise<string
|
||||
|
||||
if (offerError) throw offerError;
|
||||
|
||||
// 4. Update the lock with offer reference
|
||||
try {
|
||||
await supabase.rpc('lock_escrow_internal', {
|
||||
p_user_id: userId,
|
||||
p_token: token,
|
||||
p_amount: 0, // Just updating reference, not locking more
|
||||
p_reference_type: 'offer',
|
||||
p_reference_id: offer.id
|
||||
});
|
||||
} catch {
|
||||
// Non-critical, just for tracking
|
||||
}
|
||||
// (Removed) The previous amount=0 lock_escrow_internal "reference update" call
|
||||
// was a no-op that only logged a zero-amount ledger row. lock_escrow_internal
|
||||
// is now service-role only, so this cosmetic call is dropped entirely.
|
||||
|
||||
// 5. Audit log
|
||||
await logAction('offer', offer.id, 'create_offer', {
|
||||
@@ -632,83 +695,54 @@ export async function markPaymentSent(
|
||||
*
|
||||
* Buyer can later withdraw to external wallet if needed (separate blockchain tx).
|
||||
*/
|
||||
export async function confirmPaymentReceived(tradeId: string, sellerId: string): Promise<void> {
|
||||
export async function confirmPaymentReceived(
|
||||
tradeId: string,
|
||||
sellerIdentity: string,
|
||||
signerAddress: string,
|
||||
signMessage: (message: string) => Promise<string>
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!sellerId) throw new Error('Identity required for P2P trading');
|
||||
if (!sellerIdentity) throw new Error('Identity required for P2P trading');
|
||||
if (!signerAddress) throw new Error('Connect a wallet to release escrow');
|
||||
|
||||
// 2. Get trade details
|
||||
const { data: trade, error: tradeError } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('*')
|
||||
.eq('id', tradeId)
|
||||
.single();
|
||||
|
||||
if (tradeError) throw tradeError;
|
||||
if (!trade) throw new Error('Trade not found');
|
||||
|
||||
// Verify caller is the seller
|
||||
if (trade.seller_id !== sellerId) {
|
||||
throw new Error('Only seller can confirm payment');
|
||||
}
|
||||
|
||||
if (trade.status !== 'payment_sent') {
|
||||
throw new Error('Payment has not been marked as sent');
|
||||
}
|
||||
|
||||
// 3. Get offer to get token type
|
||||
const { data: offer } = await supabase
|
||||
.from('p2p_fiat_offers')
|
||||
.select('token')
|
||||
.eq('id', trade.offer_id)
|
||||
.single();
|
||||
|
||||
if (!offer) throw new Error('Offer not found');
|
||||
|
||||
toast.info('Releasing crypto to buyer...');
|
||||
|
||||
// 4. Release escrow internally (NO blockchain tx!)
|
||||
// This transfers from seller's locked_balance to buyer's available_balance
|
||||
const { data: releaseResult, error: releaseError } = await supabase.rpc('release_escrow_internal', {
|
||||
p_from_user_id: trade.seller_id,
|
||||
p_to_user_id: trade.buyer_id,
|
||||
p_token: offer.token,
|
||||
p_amount: trade.crypto_amount,
|
||||
p_reference_type: 'trade',
|
||||
p_reference_id: tradeId
|
||||
// Release is a money-OUT action: it is authorized ONLY via the confirm-payment
|
||||
// edge function, which verifies the seller's wallet signature + identity
|
||||
// ownership before calling release_escrow_internal with the service role.
|
||||
// release_escrow_internal is no longer callable with the anon key.
|
||||
const timestamp = Date.now();
|
||||
const nonce = `cp-${timestamp}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
||||
const challenge = buildConfirmPaymentChallenge({
|
||||
tradeId,
|
||||
sellerIdentity,
|
||||
signerAddress,
|
||||
timestamp,
|
||||
nonce,
|
||||
});
|
||||
|
||||
if (releaseError) throw releaseError;
|
||||
toast.info('Sign to release crypto to the buyer...');
|
||||
const signature = await signMessage(challenge);
|
||||
|
||||
// Parse result
|
||||
const releaseResponse = typeof releaseResult === 'string' ? JSON.parse(releaseResult) : releaseResult;
|
||||
const { data, error } = await supabase.functions.invoke('confirm-payment', {
|
||||
body: { tradeId, sellerIdentity, signerAddress, signature, timestamp, nonce },
|
||||
});
|
||||
|
||||
if (!releaseResponse.success) {
|
||||
throw new Error(releaseResponse.error || 'Failed to release escrow');
|
||||
if (error) throw error;
|
||||
if (!data?.success) {
|
||||
throw new Error(data?.error || 'Failed to release escrow');
|
||||
}
|
||||
|
||||
// 5. Update trade status
|
||||
const { error: updateError } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.update({
|
||||
seller_confirmed_at: new Date().toISOString(),
|
||||
escrow_released_at: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
completed_at: new Date().toISOString()
|
||||
// NOTE: No escrow_release_tx_hash - internal ledger doesn't use blockchain during trades
|
||||
})
|
||||
.eq('id', tradeId);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
// 6. Update reputations
|
||||
await updateReputations(trade.seller_id, trade.buyer_id, tradeId);
|
||||
|
||||
// 7. Audit log
|
||||
await logAction('trade', tradeId, 'confirm_payment', {
|
||||
released_amount: trade.crypto_amount,
|
||||
token: offer.token,
|
||||
escrow_type: 'internal_ledger'
|
||||
}, sellerId);
|
||||
// Non-financial follow-ups (best effort). The escrow move + trade status
|
||||
// change already happened atomically server-side.
|
||||
try {
|
||||
await updateReputations(data.sellerId, data.buyerId, tradeId);
|
||||
await logAction('trade', tradeId, 'confirm_payment', {
|
||||
released_amount: data.amount,
|
||||
token: data.token,
|
||||
escrow_type: 'internal_ledger'
|
||||
}, data.sellerId);
|
||||
} catch (followupErr) {
|
||||
console.error('Post-release follow-up failed (non-critical):', followupErr);
|
||||
}
|
||||
|
||||
toast.success('Payment confirmed! Crypto released to buyer\'s balance.');
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -25,7 +26,8 @@ interface CreateAdProps {
|
||||
export function CreateAd({ onAdCreated }: CreateAdProps) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedAccount } = usePezkuwi();
|
||||
const { userId } = useP2PIdentity();
|
||||
const { signMessage } = useWallet();
|
||||
const { userId, identityId } = useP2PIdentity();
|
||||
|
||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<PaymentMethod | null>(null);
|
||||
@@ -77,7 +79,7 @@ export function CreateAd({ onAdCreated }: CreateAdProps) {
|
||||
};
|
||||
|
||||
const handleCreateAd = async () => {
|
||||
if (!selectedAccount || !userId) {
|
||||
if (!selectedAccount || !userId || !identityId) {
|
||||
toast.error(t('p2p.connectWalletAndLogin'));
|
||||
return;
|
||||
}
|
||||
@@ -128,6 +130,8 @@ export function CreateAd({ onAdCreated }: CreateAdProps) {
|
||||
try {
|
||||
await createFiatOffer({
|
||||
userId,
|
||||
identityId: identityId!,
|
||||
signMessage,
|
||||
sellerWallet: selectedAccount.address,
|
||||
token,
|
||||
amountCrypto: cryptoAmt,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Star,
|
||||
} from 'lucide-react';
|
||||
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import {
|
||||
@@ -74,7 +75,8 @@ export default function P2PTrade() {
|
||||
const { tradeId } = useParams<{ tradeId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { userId } = useP2PIdentity();
|
||||
const { userId, identityId, walletAddress } = useP2PIdentity();
|
||||
const { signMessage } = useWallet();
|
||||
|
||||
const [trade, setTrade] = useState<TradeWithDetails | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -295,14 +297,14 @@ export default function P2PTrade() {
|
||||
|
||||
// Handle release crypto
|
||||
const handleReleaseCrypto = async () => {
|
||||
if (!trade || !userId) {
|
||||
if (!trade || !userId || !identityId || !walletAddress) {
|
||||
toast.error(t('p2p.connectWalletAndLogin'));
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await confirmPaymentReceived(trade.id, userId);
|
||||
await confirmPaymentReceived(trade.id, identityId, walletAddress, signMessage);
|
||||
toast.success(t('p2pTrade.cryptoReleasedToast'));
|
||||
fetchTrade();
|
||||
} catch (error) {
|
||||
|
||||
@@ -141,6 +141,50 @@ export function buildDepositChallenge(p: {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical challenge for a seller confirming payment / releasing escrow.
|
||||
* MUST be byte-identical to the client (shared/lib/p2p-fiat.ts).
|
||||
*/
|
||||
export function buildConfirmPaymentChallenge(p: {
|
||||
tradeId: string
|
||||
sellerIdentity: string
|
||||
signerAddress: string
|
||||
timestamp: number
|
||||
nonce: string
|
||||
}): string {
|
||||
return [
|
||||
'Pezkuwi P2P Confirm Payment',
|
||||
`trade:${p.tradeId}`,
|
||||
`identity:${p.sellerIdentity}`,
|
||||
`signer:${p.signerAddress}`,
|
||||
`timestamp:${p.timestamp}`,
|
||||
`nonce:${p.nonce}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical challenge for locking one's OWN balance into escrow (offer create).
|
||||
* MUST be byte-identical to the client (shared/lib/p2p-fiat.ts).
|
||||
*/
|
||||
export function buildLockEscrowChallenge(p: {
|
||||
identityId: string
|
||||
token: string
|
||||
amount: number
|
||||
signerAddress: string
|
||||
timestamp: number
|
||||
nonce: string
|
||||
}): string {
|
||||
return [
|
||||
'Pezkuwi P2P Lock Escrow',
|
||||
`identity:${p.identityId}`,
|
||||
`token:${p.token}`,
|
||||
`amount:${p.amount}`,
|
||||
`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).
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// confirm-payment Edge Function
|
||||
//
|
||||
// Seller-authorized escrow RELEASE (the money-OUT path that was previously
|
||||
// callable by anyone holding the public anon key via rpc('release_escrow_internal')).
|
||||
//
|
||||
// Authorization chain:
|
||||
// 1. Seller signs a canonical challenge with their wallet.
|
||||
// 2. verifyWalletSignature (raw + <Bytes>) proves control of `signerAddress`.
|
||||
// 3. assertIdentityOwnedByWallet proves the wallet owns the claimed seller
|
||||
// identity (citizen NFT / active visa).
|
||||
// 4. The derived seller user_id MUST equal the trade's seller_id, and the
|
||||
// trade MUST be in 'payment_sent'.
|
||||
// 5. Single-use nonce (replay protection).
|
||||
// 6. Only then release_escrow_internal(seller -> buyer) runs with the service
|
||||
// role, atomically with the trade status update.
|
||||
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
import { ApiPromise, WsProvider } from 'npm:@pezkuwi/api@16.5.36'
|
||||
import {
|
||||
identityToUUID,
|
||||
verifyWalletSignature,
|
||||
buildConfirmPaymentChallenge,
|
||||
assertIdentityOwnedByWallet,
|
||||
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',
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
peopleApiInstance = await ApiPromise.create({ provider: new WsProvider(PEOPLE_RPC_ENDPOINT) })
|
||||
return peopleApiInstance
|
||||
}
|
||||
|
||||
interface Body {
|
||||
tradeId?: string
|
||||
sellerIdentity?: string
|
||||
signerAddress?: 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 serviceClient = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)
|
||||
const { tradeId, sellerIdentity, signerAddress, signature, timestamp, nonce }: Body = await req.json()
|
||||
|
||||
if (!tradeId || !sellerIdentity || !signerAddress || !signature || !nonce || typeof timestamp !== 'number') {
|
||||
return json(401, { success: false, error: 'Missing authorization fields' })
|
||||
}
|
||||
if (!isFreshTimestamp(timestamp)) {
|
||||
return json(401, { success: false, error: 'Authorization challenge expired. Please retry.' })
|
||||
}
|
||||
|
||||
// 1) Verify signature
|
||||
const challenge = buildConfirmPaymentChallenge({ tradeId, sellerIdentity, signerAddress, timestamp, nonce })
|
||||
if (!(await verifyWalletSignature(challenge, signature, signerAddress))) {
|
||||
return json(401, { success: false, error: 'Invalid signature' })
|
||||
}
|
||||
|
||||
// 2) Prove the signer owns the claimed seller identity
|
||||
const ownership = await assertIdentityOwnedByWallet(serviceClient, sellerIdentity, signerAddress, getPeopleApi)
|
||||
if (!ownership.ok) {
|
||||
return json(403, { success: false, error: ownership.error || 'Identity ownership verification failed' })
|
||||
}
|
||||
const sellerUserId = await identityToUUID(sellerIdentity)
|
||||
|
||||
// 3) Load trade and enforce it belongs to this seller and is releasable
|
||||
const { data: trade, error: tradeErr } = await serviceClient
|
||||
.from('p2p_fiat_trades')
|
||||
.select('id, seller_id, buyer_id, offer_id, crypto_amount, status')
|
||||
.eq('id', tradeId)
|
||||
.single()
|
||||
if (tradeErr || !trade) return json(404, { success: false, error: 'Trade not found' })
|
||||
if (trade.seller_id !== sellerUserId) {
|
||||
return json(403, { success: false, error: 'Only the trade seller can release this escrow' })
|
||||
}
|
||||
if (trade.status !== 'payment_sent') {
|
||||
return json(400, { success: false, error: `Trade is not awaiting confirmation (status: ${trade.status})` })
|
||||
}
|
||||
|
||||
// 4) Resolve token from offer
|
||||
const { data: offer } = await serviceClient
|
||||
.from('p2p_fiat_offers')
|
||||
.select('token')
|
||||
.eq('id', trade.offer_id)
|
||||
.single()
|
||||
if (!offer?.token) return json(400, { success: false, error: 'Offer/token not found' })
|
||||
|
||||
// 5) Consume nonce (single use)
|
||||
if (!(await consumeNonce(serviceClient, nonce, 'confirm_payment'))) {
|
||||
return json(401, { success: false, error: 'Authorization already used (replay detected)' })
|
||||
}
|
||||
|
||||
// 6) Release escrow (service role) + mark trade completed
|
||||
const { data: releaseRes, error: releaseErr } = await serviceClient.rpc('release_escrow_internal', {
|
||||
p_from_user_id: trade.seller_id,
|
||||
p_to_user_id: trade.buyer_id,
|
||||
p_token: offer.token,
|
||||
p_amount: trade.crypto_amount,
|
||||
p_reference_type: 'trade',
|
||||
p_reference_id: tradeId,
|
||||
})
|
||||
if (releaseErr) return json(500, { success: false, error: releaseErr.message || 'Release failed' })
|
||||
const parsed = typeof releaseRes === 'string' ? JSON.parse(releaseRes) : releaseRes
|
||||
if (!parsed?.success) return json(400, { success: false, error: parsed?.error || 'Release failed' })
|
||||
|
||||
const nowIso = new Date().toISOString()
|
||||
const { error: updErr } = await serviceClient
|
||||
.from('p2p_fiat_trades')
|
||||
.update({
|
||||
seller_confirmed_at: nowIso,
|
||||
escrow_released_at: nowIso,
|
||||
status: 'completed',
|
||||
completed_at: nowIso,
|
||||
})
|
||||
.eq('id', tradeId)
|
||||
if (updErr) {
|
||||
// Funds released but status update failed — log for reconciliation.
|
||||
console.error('Trade status update failed after release:', updErr)
|
||||
}
|
||||
|
||||
return json(200, {
|
||||
success: true,
|
||||
tradeId,
|
||||
sellerId: trade.seller_id,
|
||||
buyerId: trade.buyer_id,
|
||||
token: offer.token,
|
||||
amount: trade.crypto_amount,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('confirm-payment error:', error)
|
||||
return json(500, { success: false, error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
// lock-escrow Edge Function
|
||||
//
|
||||
// Locks a caller's OWN available balance into escrow (used by offer creation).
|
||||
// lock_escrow_internal only ever moves the caller's own funds available->locked,
|
||||
// so it is not a theft vector — but an anon caller could previously lock a
|
||||
// VICTIM's balance (griefing/DoS on their funds). This gates it so a caller can
|
||||
// only ever lock the balance of the identity their wallet owns.
|
||||
//
|
||||
// Authorization: wallet signature -> identity ownership -> derived user_id ->
|
||||
// lock_escrow_internal with the service role.
|
||||
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
import { ApiPromise, WsProvider } from 'npm:@pezkuwi/api@16.5.36'
|
||||
import {
|
||||
identityToUUID,
|
||||
verifyWalletSignature,
|
||||
buildLockEscrowChallenge,
|
||||
assertIdentityOwnedByWallet,
|
||||
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',
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
peopleApiInstance = await ApiPromise.create({ provider: new WsProvider(PEOPLE_RPC_ENDPOINT) })
|
||||
return peopleApiInstance
|
||||
}
|
||||
|
||||
interface Body {
|
||||
identityId?: string
|
||||
token?: 'HEZ' | 'PEZ'
|
||||
amount?: number
|
||||
signerAddress?: 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 serviceClient = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)
|
||||
const { identityId, token, amount, signerAddress, signature, timestamp, nonce }: Body = await req.json()
|
||||
|
||||
if (!identityId || !token || !signerAddress || !signature || !nonce || typeof timestamp !== 'number') {
|
||||
return json(401, { success: false, error: 'Missing authorization fields' })
|
||||
}
|
||||
if (!['HEZ', 'PEZ'].includes(token)) {
|
||||
return json(400, { success: false, error: 'Invalid token' })
|
||||
}
|
||||
const amt = Number(amount)
|
||||
if (!Number.isFinite(amt) || amt <= 0) {
|
||||
return json(400, { success: false, error: 'Amount must be greater than 0' })
|
||||
}
|
||||
if (!isFreshTimestamp(timestamp)) {
|
||||
return json(401, { success: false, error: 'Authorization challenge expired. Please retry.' })
|
||||
}
|
||||
|
||||
// 1) Verify signature over the exact lock intent
|
||||
const challenge = buildLockEscrowChallenge({ identityId, token, amount: amt, signerAddress, timestamp, nonce })
|
||||
if (!(await verifyWalletSignature(challenge, signature, signerAddress))) {
|
||||
return json(401, { success: false, error: 'Invalid signature' })
|
||||
}
|
||||
|
||||
// 2) Prove the signer owns the identity whose balance is being locked
|
||||
const ownership = await assertIdentityOwnedByWallet(serviceClient, identityId, signerAddress, getPeopleApi)
|
||||
if (!ownership.ok) {
|
||||
return json(403, { success: false, error: ownership.error || 'Identity ownership verification failed' })
|
||||
}
|
||||
const userId = await identityToUUID(identityId)
|
||||
|
||||
// 3) Replay protection
|
||||
if (!(await consumeNonce(serviceClient, nonce, 'lock_escrow'))) {
|
||||
return json(401, { success: false, error: 'Authorization already used (replay detected)' })
|
||||
}
|
||||
|
||||
// 4) Lock the caller's OWN balance (service role)
|
||||
const { data: lockRes, error: lockErr } = await serviceClient.rpc('lock_escrow_internal', {
|
||||
p_user_id: userId,
|
||||
p_token: token,
|
||||
p_amount: amt,
|
||||
})
|
||||
if (lockErr) return json(500, { success: false, error: lockErr.message || 'Lock failed' })
|
||||
const parsed = typeof lockRes === 'string' ? JSON.parse(lockRes) : lockRes
|
||||
if (!parsed?.success) return json(400, { success: false, error: parsed?.error || 'Lock failed' })
|
||||
|
||||
return json(200, { success: true, userId, token, amount: amt, ...parsed })
|
||||
} catch (error) {
|
||||
console.error('lock-escrow error:', error)
|
||||
return json(500, { success: false, error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
-- =====================================================
|
||||
-- Migration: Lock down internal-ledger escrow RPCs
|
||||
-- Date: 2026-07-25
|
||||
-- =====================================================
|
||||
--
|
||||
-- Fix for the CRITICAL drain: lock_/release_/refund_escrow_internal (migration
|
||||
-- 014) had default PUBLIC EXECUTE, and the client called release_escrow_internal
|
||||
-- directly with the public anon key. An anon caller could therefore run
|
||||
-- release_escrow_internal(victim, attacker, token, amount)
|
||||
-- and drain any victim's LOCKED balance.
|
||||
--
|
||||
-- These functions now execute ONLY as the service role. Every legitimate caller
|
||||
-- goes through a service-role edge function that first verifies a wallet
|
||||
-- signature + identity ownership:
|
||||
-- * release -> confirm-payment edge function (seller-authorized)
|
||||
-- * lock -> lock-escrow edge function (owner-authorized)
|
||||
-- * refund -> process-withdraw (on failed payout) + admin_resolve_dispute
|
||||
-- (dispute refunds/splits). No direct client caller.
|
||||
--
|
||||
-- SECURITY DEFINER functions that call these internally (admin_resolve_dispute,
|
||||
-- cancel_expired_trades, process_deposit) are unaffected: EXECUTE for an inner
|
||||
-- call is checked against the function OWNER, not the session role.
|
||||
|
||||
-- release_escrow_internal(p_from_user_id, p_to_user_id, p_token, p_amount, p_reference_type, p_reference_id)
|
||||
REVOKE EXECUTE ON FUNCTION release_escrow_internal(UUID, UUID, TEXT, DECIMAL, TEXT, UUID)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION release_escrow_internal(UUID, UUID, TEXT, DECIMAL, TEXT, UUID)
|
||||
TO service_role;
|
||||
|
||||
-- refund_escrow_internal(p_user_id, p_token, p_amount, p_reference_type, p_reference_id)
|
||||
REVOKE EXECUTE ON FUNCTION refund_escrow_internal(UUID, TEXT, DECIMAL, TEXT, UUID)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION refund_escrow_internal(UUID, TEXT, DECIMAL, TEXT, UUID)
|
||||
TO service_role;
|
||||
|
||||
-- lock_escrow_internal(p_user_id, p_token, p_amount, p_reference_type, p_reference_id)
|
||||
REVOKE EXECUTE ON FUNCTION lock_escrow_internal(UUID, TEXT, DECIMAL, TEXT, UUID)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION lock_escrow_internal(UUID, TEXT, DECIMAL, TEXT, UUID)
|
||||
TO service_role;
|
||||
|
||||
COMMENT ON FUNCTION release_escrow_internal IS
|
||||
'Release escrow seller->buyer. SERVICE ROLE ONLY — invoked by the confirm-payment
|
||||
edge function after verifying the seller''s wallet signature + identity ownership.';
|
||||
|
||||
COMMENT ON FUNCTION refund_escrow_internal IS
|
||||
'Refund locked escrow to its owner. SERVICE ROLE ONLY — invoked by process-withdraw
|
||||
(failed payout) and admin_resolve_dispute (dispute refund/split).';
|
||||
|
||||
COMMENT ON FUNCTION lock_escrow_internal IS
|
||||
'Lock a user''s own available balance into escrow. SERVICE ROLE ONLY — invoked by
|
||||
the lock-escrow edge function after verifying the caller owns that identity.';
|
||||
Reference in New Issue
Block a user