From cbf98e4dc9ab5667ce71ffc17955a1969738f510 Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Mon, 23 Feb 2026 12:16:15 +0300 Subject: [PATCH] fix: verify-deposit blockchain verification and wallet-based auth - Drop auth.users FK constraints for wallet-based authentication - Fix deferrable unique constraint on blockchain_tx_hash (ON CONFLICT compat) - Rewrite block search: HTTP RPC + blake2b instead of WS-only @pezkuwi/api - Add blockNumber hint for faster verification of older transactions - Normalize SS58/hex addresses via base58 for reliable comparison - DepositModal captures approximate block number after tx submission --- web/src/components/p2p/DepositModal.tsx | 12 +- .../functions/verify-deposit/index.ts | 305 +++++++++++++----- ...223120000_drop_auth_fk_for_wallet_auth.sql | 46 +++ .../20260223130000_fix_tx_hash_constraint.sql | 18 ++ 4 files changed, 297 insertions(+), 84 deletions(-) create mode 100644 web/supabase/migrations/20260223120000_drop_auth_fk_for_wallet_auth.sql create mode 100644 web/supabase/migrations/20260223130000_fix_tx_hash_constraint.sql diff --git a/web/src/components/p2p/DepositModal.tsx b/web/src/components/p2p/DepositModal.tsx index 75605077..53bd113b 100644 --- a/web/src/components/p2p/DepositModal.tsx +++ b/web/src/components/p2p/DepositModal.tsx @@ -56,6 +56,7 @@ export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps) const [amount, setAmount] = useState(''); const [platformWallet, setPlatformWallet] = useState(''); const [txHash, setTxHash] = useState(''); + const [blockNumber, setBlockNumber] = useState(); const [loading, setLoading] = useState(false); const [copied, setCopied] = useState(false); const [verifying, setVerifying] = useState(false); @@ -77,6 +78,7 @@ export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps) setToken('HEZ'); setAmount(''); setTxHash(''); + setBlockNumber(undefined); setLoading(false); setCopied(false); setVerifying(false); @@ -140,6 +142,13 @@ export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps) if (hash) { setTxHash(hash); + // Capture approximate block number for faster verification + try { + const header = await api.rpc.chain.getHeader(); + setBlockNumber(header.number.toNumber()); + } catch { + // Non-critical - verification will still work via search + } setStep('verify'); toast.success(t('p2pDeposit.txSent')); } @@ -174,7 +183,8 @@ export function DepositModal({ isOpen, onClose, onSuccess }: DepositModalProps) txHash, token, expectedAmount: depositAmount, - walletAddress: selectedAccount?.address + walletAddress: selectedAccount?.address, + ...(blockNumber ? { blockNumber } : {}) } }); diff --git a/web/supabase/functions/verify-deposit/index.ts b/web/supabase/functions/verify-deposit/index.ts index 0adfbd42..60fab0b0 100644 --- a/web/supabase/functions/verify-deposit/index.ts +++ b/web/supabase/functions/verify-deposit/index.ts @@ -1,10 +1,12 @@ // verify-deposit Edge Function // OKX-level security: Verifies blockchain transactions before crediting balances -// Uses @pezkuwi/api for blockchain verification +// Uses HTTP RPC for block search + @pezkuwi/api for event verification 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.11' +import { blake2b } from 'npm:@noble/hashes@1.7.1/blake2b' +import { base58 } from 'npm:@scure/base@1.2.4' // Allowed origins for CORS const ALLOWED_ORIGINS = [ @@ -25,12 +27,36 @@ function getCorsHeaders(origin: string | null) { // Platform hot wallet address (PRODUCTION) - Treasury_3 const PLATFORM_WALLET = '5H18ZZBU4LwPYbeEZ1JBGvibCU2edhhM8HNUtFi7GgC36CgS' -// RPC endpoint for PezkuwiChain -const RPC_ENDPOINT = 'wss://rpc.pezkuwichain.io' +// RPC endpoints for PezkuwiChain +const RPC_HTTP = 'https://rpc.pezkuwichain.io' +const RPC_WS = 'wss://rpc.pezkuwichain.io' // Token decimals const DECIMALS = 12 +// Generate deterministic UUID v5 from wallet address +async function walletToUUID(walletAddress: string): Promise { + const NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8' + const data = new TextEncoder().encode(walletAddress) + const namespaceBytes = new Uint8Array(16) + const hex = NAMESPACE.replace(/-/g, '') + for (let i = 0; i < 16; i++) { + namespaceBytes[i] = parseInt(hex.substr(i * 2, 2), 16) + } + const combined = new Uint8Array(namespaceBytes.length + data.length) + combined.set(namespaceBytes) + combined.set(data, namespaceBytes.length) + + const hashBuffer = await crypto.subtle.digest('SHA-1', combined) + const hashArray = new Uint8Array(hashBuffer) + + hashArray[6] = (hashArray[6] & 0x0f) | 0x50 + hashArray[8] = (hashArray[8] & 0x3f) | 0x80 + + const hex2 = Array.from(hashArray.slice(0, 16)).map(b => b.toString(16).padStart(2, '0')).join('') + return `${hex2.slice(0,8)}-${hex2.slice(8,12)}-${hex2.slice(12,16)}-${hex2.slice(16,20)}-${hex2.slice(20,32)}` +} + // PEZ asset ID const PEZ_ASSET_ID = 1 @@ -39,93 +65,179 @@ interface DepositRequest { token: 'HEZ' | 'PEZ' expectedAmount: number walletAddress: string + blockNumber?: number } -// Cache API connection +// --- HTTP RPC helpers --- +function hexToBytes(hex: string): Uint8Array { + const clean = hex.startsWith('0x') ? hex.slice(2) : hex + const bytes = new Uint8Array(clean.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(clean.substr(i * 2, 2), 16) + } + return bytes +} + +function bytesToHex(bytes: Uint8Array): string { + return '0x' + Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') +} + +async function rpcCall(method: string, params: unknown[] = []): Promise { + const res = await fetch(RPC_HTTP, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 1, jsonrpc: '2.0', method, params }) + }) + const data = await res.json() + if (data.error) throw new Error(`RPC ${method}: ${data.error.message}`) + return data.result +} + +// Search a single block for the transaction hash using HTTP RPC + blake2b +async function searchBlockHttp( + blockNumber: number, + txHash: string +): Promise<{ blockHash: string; extrinsicIndex: number } | null> { + const blockHash = await rpcCall('chain_getBlockHash', [blockNumber]) as string + if (!blockHash) return null + + // deno-lint-ignore no-explicit-any + const blockData = await rpcCall('chain_getBlock', [blockHash]) as any + if (!blockData?.block?.extrinsics) return null + + const extrinsics: string[] = blockData.block.extrinsics + + for (let j = 0; j < extrinsics.length; j++) { + const extBytes = hexToBytes(extrinsics[j]) + const hash = blake2b(extBytes, { dkLen: 32 }) + const extHash = bytesToHex(hash) + + if (extHash === txHash) { + return { blockHash, extrinsicIndex: j } + } + } + return null +} + +// Get latest block number via HTTP RPC +async function getLatestBlockNumber(): Promise { + // deno-lint-ignore no-explicit-any + const header = await rpcCall('chain_getHeader') as any + return parseInt(header.number, 16) +} + +// Find transaction in blocks - returns blockHash + extrinsicIndex +async function findTransaction( + txHash: string, + hintBlockNumber?: number +): Promise<{ blockHash: string; extrinsicIndex: number; blockNumber: number } | null> { + const latestBlock = await getLatestBlockNumber() + + // Strategy 1: Check hint block and neighbors + if (hintBlockNumber && hintBlockNumber > 0 && hintBlockNumber <= latestBlock) { + console.log(`Searching hint block ${hintBlockNumber} and neighbors...`) + for (const offset of [0, -1, 1, -2, 2, -3, 3]) { + const bn = hintBlockNumber + offset + if (bn > 0 && bn <= latestBlock) { + try { + const result = await searchBlockHttp(bn, txHash) + if (result) { + console.log(`Found in block ${bn}`) + return { ...result, blockNumber: bn } + } + } catch (err) { + console.error(`Error searching block ${bn}:`, err) + } + } + } + } + + // Strategy 2: Search recent blocks in parallel batches + const searchDepth = 300 + const batchSize = 10 + console.log(`Searching last ${searchDepth} blocks from #${latestBlock}...`) + + for (let i = 0; i < searchDepth; i += batchSize) { + const promises: Promise<{ blockHash: string; extrinsicIndex: number; blockNumber: number } | null>[] = [] + for (let j = 0; j < batchSize && (i + j) < searchDepth; j++) { + const bn = latestBlock - (i + j) + if (bn < 0) break + promises.push( + searchBlockHttp(bn, txHash) + .then(r => r ? { ...r, blockNumber: bn } : null) + .catch(() => null) + ) + } + + const results = await Promise.all(promises) + for (const result of results) { + if (result) return result + } + } + + return null +} + +// Cache API connection for event verification let apiInstance: ApiPromise | null = null async function getApi(): Promise { if (apiInstance && apiInstance.isConnected) { return apiInstance } - - const provider = new WsProvider(RPC_ENDPOINT) + const provider = new WsProvider(RPC_WS) apiInstance = await ApiPromise.create({ provider }) return apiInstance } -// Verify transaction on blockchain using @pezkuwi/api +// Verify transaction events using @pezkuwi/api async function verifyTransactionOnChain( txHash: string, token: string, - expectedAmount: number + expectedAmount: number, + hintBlockNumber?: number ): Promise<{ valid: boolean; actualAmount?: number; from?: string; error?: string }> { try { - // Validate transaction hash format (0x + 64 hex chars) if (!txHash.match(/^0x[a-fA-F0-9]{64}$/)) { return { valid: false, error: 'Invalid transaction hash format' } } - const api = await getApi() + // Step 1: Find the transaction using HTTP RPC (fast, reliable) + console.log(`Finding transaction ${txHash}...`) + const found = await findTransaction(txHash, hintBlockNumber) - // Get block hash from extrinsic hash - // In Substrate, we need to find which block contains this extrinsic - - // Method 1: Query recent blocks to find the extrinsic - const latestHeader = await api.rpc.chain.getHeader() - const latestBlockNumber = latestHeader.number.toNumber() - - // Search last 100 blocks for the transaction - const searchDepth = 100 - let foundBlock = null - let foundExtrinsicIndex = -1 - - for (let i = 0; i < searchDepth; i++) { - const blockNumber = latestBlockNumber - i - if (blockNumber < 0) break - - const blockHash = await api.rpc.chain.getBlockHash(blockNumber) - const signedBlock = await api.rpc.chain.getBlock(blockHash) - - // Check each extrinsic in the block - for (let j = 0; j < signedBlock.block.extrinsics.length; j++) { - const ext = signedBlock.block.extrinsics[j] - const extHash = ext.hash.toHex() - - if (extHash === txHash) { - foundBlock = { hash: blockHash, number: blockNumber, block: signedBlock } - foundExtrinsicIndex = j - break - } - } - - if (foundBlock) break - } - - if (!foundBlock) { + if (!found) { + const latest = await getLatestBlockNumber() return { valid: false, - error: 'Transaction not found in recent blocks. It may be too old or not yet finalized.' + error: `Transaction not found (searched hint block ${hintBlockNumber || 'none'} and last 300 blocks from #${latest}). The transaction may be too old.` } } - // Get events for this block - const apiAt = await api.at(foundBlock.hash) + console.log(`Transaction found in block #${found.blockNumber}, extrinsic index ${found.extrinsicIndex}`) + + // Step 2: Verify events using @pezkuwi/api (needs type registry) + const api = await getApi() + + const apiAt = await api.at(found.blockHash) const events = await apiAt.query.system.events() - // Find transfer events for our extrinsic - const extrinsicEvents = events.filter((event) => { + // Find events for our extrinsic + // deno-lint-ignore no-explicit-any + const extrinsicEvents = (events as any[]).filter((event: any) => { const { phase } = event - return phase.isApplyExtrinsic && phase.asApplyExtrinsic.toNumber() === foundExtrinsicIndex + return phase.isApplyExtrinsic && phase.asApplyExtrinsic.toNumber() === found.extrinsicIndex }) // Check for success - const successEvent = extrinsicEvents.find((event) => + // deno-lint-ignore no-explicit-any + const successEvent = extrinsicEvents.find((event: any) => api.events.system.ExtrinsicSuccess.is(event.event) ) if (!successEvent) { - const failedEvent = extrinsicEvents.find((event) => + // deno-lint-ignore no-explicit-any + const failedEvent = extrinsicEvents.find((event: any) => api.events.system.ExtrinsicFailed.is(event.event) ) if (failedEvent) { @@ -135,17 +247,17 @@ async function verifyTransactionOnChain( } // Find transfer event - let transferEvent = null + // deno-lint-ignore no-explicit-any + let transferEvent: any = null let from = '' let to = '' let amount = BigInt(0) if (token === 'HEZ') { - // Native token transfer (balances.Transfer) - transferEvent = extrinsicEvents.find((event) => + // deno-lint-ignore no-explicit-any + transferEvent = extrinsicEvents.find((event: any) => api.events.balances.Transfer.is(event.event) ) - if (transferEvent) { const [fromAddr, toAddr, value] = transferEvent.event.data from = fromAddr.toString() @@ -153,19 +265,15 @@ async function verifyTransactionOnChain( amount = BigInt(value.toString()) } } else if (token === 'PEZ') { - // Asset transfer (assets.Transferred) - transferEvent = extrinsicEvents.find((event) => + // deno-lint-ignore no-explicit-any + transferEvent = extrinsicEvents.find((event: any) => api.events.assets.Transferred.is(event.event) ) - if (transferEvent) { const [assetId, fromAddr, toAddr, value] = transferEvent.event.data - - // Verify it's the correct asset if (assetId.toNumber() !== PEZ_ASSET_ID) { return { valid: false, error: 'Wrong asset transferred' } } - from = fromAddr.toString() to = toAddr.toString() amount = BigInt(value.toString()) @@ -176,14 +284,39 @@ async function verifyTransactionOnChain( return { valid: false, error: 'No transfer event found in transaction' } } - // Verify recipient is platform wallet - if (to !== PLATFORM_WALLET) { - return { - valid: false, - error: `Transaction recipient (${to}) does not match platform wallet` + // Normalize addresses to raw hex for reliable comparison + // Event data may return raw hex or SS58 with different prefix + const toRawHex = (addr: string): string => { + if (addr.startsWith('0x') && addr.length === 66) { + return addr.toLowerCase() + } + // Decode SS58: base58decode -> remove prefix byte(s) and 2-byte checksum + try { + const decoded = base58.decode(addr) + // Simple SS58: 1 prefix byte + 32 pubkey + 2 checksum = 35 bytes + // Extended: 2 prefix bytes + 32 pubkey + 2 checksum = 36 bytes + const pubkey = decoded.length === 35 + ? decoded.slice(1, 33) + : decoded.slice(2, 34) + return '0x' + Array.from(pubkey).map(b => b.toString(16).padStart(2, '0')).join('') + } catch { + return addr } } + const normalizedTo = toRawHex(to) + const normalizedPlatform = toRawHex(PLATFORM_WALLET) + + // Verify recipient is platform wallet + if (normalizedTo !== normalizedPlatform) { + return { + valid: false, + error: `Transaction recipient (${normalizedTo}) does not match platform wallet (${normalizedPlatform})` + } + } + + // Keep from as-is (will be compared as raw hex later) + // Convert amount to human readable const actualAmount = Number(amount) / Math.pow(10, DECIMALS) @@ -202,7 +335,7 @@ async function verifyTransactionOnChain( const finalizedHeader = await api.rpc.chain.getHeader(finalizedHash) const finalizedNumber = finalizedHeader.number.toNumber() - if (foundBlock.number > finalizedNumber) { + if (found.blockNumber > finalizedNumber) { return { valid: false, error: 'Transaction not yet finalized. Please wait a few more blocks.' @@ -219,7 +352,7 @@ async function verifyTransactionOnChain( console.error('Blockchain verification error:', error) return { valid: false, - error: `Verification failed: ${error.message}` + error: `Verification failed: ${error instanceof Error ? error.message : String(error)}` } } } @@ -227,22 +360,18 @@ async function verifyTransactionOnChain( serve(async (req) => { const corsHeaders = getCorsHeaders(req.headers.get('Origin')) - // Handle CORS preflight if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }) } try { - // Create Supabase service client const supabaseUrl = Deno.env.get('SUPABASE_URL')! const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! const serviceClient = createClient(supabaseUrl, supabaseServiceKey) - // Parse request body const body: DepositRequest = await req.json() - const { txHash, token, expectedAmount, walletAddress } = body + const { txHash, token, expectedAmount, walletAddress, blockNumber } = body - // Validate input if (!txHash || !token || !expectedAmount || !walletAddress) { return new Response( JSON.stringify({ success: false, error: 'Missing required fields: txHash, token, expectedAmount, walletAddress' }), @@ -284,11 +413,14 @@ serve(async (req) => { } } - // Create or update deposit request (processing status) + // Map wallet address to deterministic UUID + const userId = await walletToUUID(walletAddress) + + // Create or update deposit request const { data: depositRequest, error: requestError } = await serviceClient .from('p2p_deposit_withdraw_requests') .upsert({ - user_id: walletAddress, + user_id: userId, request_type: 'deposit', token, amount: expectedAmount, @@ -311,10 +443,9 @@ serve(async (req) => { // Verify transaction on blockchain console.log(`Verifying deposit: TX=${txHash}, Token=${token}, Amount=${expectedAmount}`) - const verification = await verifyTransactionOnChain(txHash, token, expectedAmount) + const verification = await verifyTransactionOnChain(txHash, token, expectedAmount, blockNumber) if (!verification.valid) { - // Update request status to failed await serviceClient .from('p2p_deposit_withdraw_requests') .update({ @@ -334,7 +465,16 @@ serve(async (req) => { } // Verify on-chain sender matches claimed wallet address - if (verification.from !== walletAddress) { + // Normalize both to raw hex for reliable comparison + const addrToHex = (addr: string): string => { + if (addr.startsWith('0x') && addr.length === 66) return addr.toLowerCase() + try { + const decoded = base58.decode(addr) + const pubkey = decoded.length === 35 ? decoded.slice(1, 33) : decoded.slice(2, 34) + return '0x' + Array.from(pubkey).map(b => b.toString(16).padStart(2, '0')).join('') + } catch { return addr } + } + if (addrToHex(verification.from || '') !== addrToHex(walletAddress)) { await serviceClient .from('p2p_deposit_withdraw_requests') .update({ @@ -353,10 +493,10 @@ serve(async (req) => { ) } - // Transaction verified! Process deposit using service role + // Process deposit const { data: processResult, error: processError } = await serviceClient .rpc('process_deposit', { - p_user_id: walletAddress, + p_user_id: userId, p_token: token, p_amount: verification.actualAmount || expectedAmount, p_tx_hash: txHash, @@ -391,7 +531,6 @@ serve(async (req) => { ) } - // Success! console.log(`Deposit successful: Wallet=${walletAddress}, Amount=${verification.actualAmount || expectedAmount} ${token}`) return new Response( diff --git a/web/supabase/migrations/20260223120000_drop_auth_fk_for_wallet_auth.sql b/web/supabase/migrations/20260223120000_drop_auth_fk_for_wallet_auth.sql new file mode 100644 index 00000000..a1bf2db3 --- /dev/null +++ b/web/supabase/migrations/20260223120000_drop_auth_fk_for_wallet_auth.sql @@ -0,0 +1,46 @@ +-- Migration: Drop auth.users FK constraints for wallet-based authentication +-- Since we moved from Supabase Auth to on-chain wallet verification, +-- user_id is now a deterministic UUID derived from wallet address (UUID v5) +-- and no longer corresponds to auth.users entries. + +-- 1. Drop FK on p2p_deposit_withdraw_requests.user_id +ALTER TABLE public.p2p_deposit_withdraw_requests + DROP CONSTRAINT IF EXISTS p2p_deposit_withdraw_requests_user_id_fkey; + +-- 2. Drop FK on p2p_deposit_withdraw_requests.processed_by +ALTER TABLE public.p2p_deposit_withdraw_requests + DROP CONSTRAINT IF EXISTS p2p_deposit_withdraw_requests_processed_by_fkey; + +-- 3. Drop FK on user_internal_balances.user_id +ALTER TABLE public.user_internal_balances + DROP CONSTRAINT IF EXISTS user_internal_balances_user_id_fkey; + +-- 4. Drop FK on p2p_balance_transactions.user_id (if table exists) +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'p2p_balance_transactions') THEN + ALTER TABLE public.p2p_balance_transactions DROP CONSTRAINT IF EXISTS p2p_balance_transactions_user_id_fkey; + END IF; +END $$; + +-- 5. Drop FK on p2p_escrow_transactions (if table exists) +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'p2p_escrow_transactions') THEN + ALTER TABLE public.p2p_escrow_transactions DROP CONSTRAINT IF EXISTS p2p_escrow_transactions_buyer_id_fkey; + ALTER TABLE public.p2p_escrow_transactions DROP CONSTRAINT IF EXISTS p2p_escrow_transactions_seller_id_fkey; + END IF; +END $$; + +-- 6. Drop FK on p2p_orders (if table exists) +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'p2p_orders') THEN + ALTER TABLE public.p2p_orders DROP CONSTRAINT IF EXISTS p2p_orders_user_id_fkey; + ALTER TABLE public.p2p_orders DROP CONSTRAINT IF EXISTS p2p_orders_merchant_id_fkey; + END IF; +END $$; + +-- 7. Drop FK on p2p_ads (if table exists) +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'p2p_ads') THEN + ALTER TABLE public.p2p_ads DROP CONSTRAINT IF EXISTS p2p_ads_merchant_id_fkey; + END IF; +END $$; diff --git a/web/supabase/migrations/20260223130000_fix_tx_hash_constraint.sql b/web/supabase/migrations/20260223130000_fix_tx_hash_constraint.sql new file mode 100644 index 00000000..fd5cb1d4 --- /dev/null +++ b/web/supabase/migrations/20260223130000_fix_tx_hash_constraint.sql @@ -0,0 +1,18 @@ +-- Fix: Make blockchain_tx_hash unique constraint non-deferrable +-- PostgreSQL ON CONFLICT does not support deferrable constraints as arbiters + +-- Drop the deferrable constraint (actual name from migration 016) +ALTER TABLE public.p2p_deposit_withdraw_requests + DROP CONSTRAINT IF EXISTS p2p_deposit_withdraw_requests_tx_hash_unique; + +-- Also try the auto-generated name pattern +ALTER TABLE public.p2p_deposit_withdraw_requests + DROP CONSTRAINT IF EXISTS unique_blockchain_tx_hash; + +-- Also try the default PostgreSQL naming convention +ALTER TABLE public.p2p_deposit_withdraw_requests + DROP CONSTRAINT IF EXISTS p2p_deposit_withdraw_requests_blockchain_tx_hash_key; + +-- Recreate as non-deferrable (standard UNIQUE) +ALTER TABLE public.p2p_deposit_withdraw_requests + ADD CONSTRAINT p2p_deposit_withdraw_requests_tx_hash_unique UNIQUE (blockchain_tx_hash);