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:
2026-07-25 00:09:03 -07:00
parent 27b4057bd4
commit a8f41cd47f
13 changed files with 1178 additions and 96 deletions
@@ -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', {