feat: unified P2P identity across Telegram mini app and pwap/web

Add p2p_user_id column to tg_users to bridge citizen/visa UUID (v5)
used by pwap/web with the Supabase Auth UUID (v4) used by the mini app.

- Migration: tg_users.p2p_user_id UUID (nullable, indexed)
- 6 P2P edge functions: replace listUsers+find with direct tg_users
  lookup — resolves userId as p2p_user_id ?? id (backwards compatible)
- Eliminates O(N) auth.admin.listUsers scan in every P2P call

When p2p_user_id is populated (via TelegramConnect wallet link),
mini app users share the same P2P balance and offers as pwap/web.
This commit is contained in:
2026-04-27 13:30:09 +03:00
parent faf0faed69
commit 698c014682
9 changed files with 245 additions and 152 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pezkuwi-telegram-miniapp",
"version": "1.0.229",
"version": "1.0.230",
"type": "module",
"description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards",
"author": "Pezkuwichain Team",
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.0.229",
"buildTime": "2026-02-27T23:33:39.279Z",
"buildNumber": 1772235219280
"version": "1.0.230",
"buildTime": "2026-04-27T10:30:10.174Z",
"buildNumber": 1777285810176
}
+20 -19
View File
@@ -130,10 +130,13 @@ serve(async (req) => {
// Validate required fields
if (!offerId || !amount || !buyerWallet) {
return new Response(JSON.stringify({ error: 'Missing required fields: offerId, amount, buyerWallet' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
return new Response(
JSON.stringify({ error: 'Missing required fields: offerId, amount, buyerWallet' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
if (amount <= 0) {
@@ -148,21 +151,22 @@ serve(async (req) => {
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers({ perPage: 1000 });
const authUser = authUsers?.find((u: { email?: string }) => u.email === telegramEmail);
// Resolve P2P user ID: prefer p2p_user_id (citizen/visa UUID, same as pwap/web)
// fallback to tg_users.id (legacy Supabase Auth UUID)
const { data: tgUser, error: tgUserError } = await supabase
.from('tg_users')
.select('id, p2p_user_id')
.eq('telegram_id', telegramId)
.single();
if (!authUser) {
if (tgUserError || !tgUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const userId = authUser.id;
const userId = tgUser.p2p_user_id ?? tgUser.id;
// Call the accept_p2p_offer RPC function
const { data: rpcResult, error: rpcError } = await supabase.rpc('accept_p2p_offer', {
@@ -187,13 +191,10 @@ serve(async (req) => {
const result = typeof rpcResult === 'string' ? JSON.parse(rpcResult) : rpcResult;
if (!result.success) {
return new Response(
JSON.stringify({ error: result.error || 'Failed to accept offer' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
return new Response(JSON.stringify({ error: result.error || 'Failed to accept offer' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Log to p2p_audit_log
@@ -160,21 +160,22 @@ serve(async (req) => {
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers({ perPage: 1000 });
const authUser = authUsers?.find((u: { email?: string }) => u.email === telegramEmail);
// Resolve P2P user ID: prefer p2p_user_id (citizen/visa UUID, same as pwap/web)
// fallback to tg_users.id (legacy Supabase Auth UUID)
const { data: tgUser, error: tgUserError } = await supabase
.from('tg_users')
.select('id, p2p_user_id')
.eq('telegram_id', telegramId)
.single();
if (!authUser) {
if (tgUserError || !tgUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const userId = authUser.id;
const userId = tgUser.p2p_user_id ?? tgUser.id;
// 1. Lock escrow from internal balance
const { data: lockResult, error: lockError } = await supabase.rpc('lock_escrow_internal', {
+74 -28
View File
@@ -28,7 +28,13 @@ interface P2PDisputeRequest {
reason?: string;
category?: 'payment_not_received' | 'wrong_amount' | 'fake_payment_proof' | 'other';
evidenceUrl?: string;
evidenceType?: 'screenshot' | 'receipt' | 'bank_statement' | 'chat_log' | 'transaction_proof' | 'other';
evidenceType?:
| 'screenshot'
| 'receipt'
| 'bank_statement'
| 'chat_log'
| 'transaction_proof'
| 'other';
description?: string;
}
@@ -97,7 +103,16 @@ serve(async (req) => {
try {
const body: P2PDisputeRequest = await req.json();
const { sessionToken, action, tradeId, reason, category, evidenceUrl, evidenceType, description } = body;
const {
sessionToken,
action,
tradeId,
reason,
category,
evidenceUrl,
evidenceType,
description,
} = body;
// Get bot tokens for session verification (dual bot support)
const botTokens: string[] = [];
@@ -141,10 +156,13 @@ serve(async (req) => {
}
if (!['open', 'add_evidence'].includes(action)) {
return new Response(JSON.stringify({ error: 'Invalid action. Must be "open" or "add_evidence"' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
return new Response(
JSON.stringify({ error: 'Invalid action. Must be "open" or "add_evidence"' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Create Supabase admin client (bypasses RLS)
@@ -152,21 +170,22 @@ serve(async (req) => {
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers({ perPage: 1000 });
const authUser = authUsers?.find((u: { email?: string }) => u.email === telegramEmail);
// Resolve P2P user ID: prefer p2p_user_id (citizen/visa UUID, same as pwap/web)
// fallback to tg_users.id (legacy Supabase Auth UUID)
const { data: tgUser, error: tgUserError } = await supabase
.from('tg_users')
.select('id, p2p_user_id')
.eq('telegram_id', telegramId)
.single();
if (!authUser) {
if (tgUserError || !tgUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const userId = authUser.id;
const userId = tgUser.p2p_user_id ?? tgUser.id;
// Verify user is a party to this trade
const { data: trade, error: tradeError } = await supabase
@@ -193,20 +212,35 @@ serve(async (req) => {
if (action === 'open') {
// Trade must be in active status to dispute
if (!['pending', 'payment_sent'].includes(trade.status)) {
return new Response(JSON.stringify({ error: `Cannot open dispute: trade status is '${trade.status}', must be 'pending' or 'payment_sent'` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
return new Response(
JSON.stringify({
error: `Cannot open dispute: trade status is '${trade.status}', must be 'pending' or 'payment_sent'`,
}),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Validate category
const validCategories = ['payment_not_received', 'wrong_amount', 'fake_payment_proof', 'other'];
const validCategories = [
'payment_not_received',
'wrong_amount',
'fake_payment_proof',
'other',
];
const disputeCategory = category || 'other';
if (!validCategories.includes(disputeCategory)) {
return new Response(JSON.stringify({ error: `Invalid category. Must be one of: ${validCategories.join(', ')}` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
return new Response(
JSON.stringify({
error: `Invalid category. Must be one of: ${validCategories.join(', ')}`,
}),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
const disputeReason = reason || 'No reason provided';
@@ -300,13 +334,25 @@ serve(async (req) => {
});
}
const validEvidenceTypes = ['screenshot', 'receipt', 'bank_statement', 'chat_log', 'transaction_proof', 'other'];
const validEvidenceTypes = [
'screenshot',
'receipt',
'bank_statement',
'chat_log',
'transaction_proof',
'other',
];
const evType = evidenceType || 'other';
if (!validEvidenceTypes.includes(evType)) {
return new Response(JSON.stringify({ error: `Invalid evidence type. Must be one of: ${validEvidenceTypes.join(', ')}` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
return new Response(
JSON.stringify({
error: `Invalid evidence type. Must be one of: ${validEvidenceTypes.join(', ')}`,
}),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Find the dispute for this trade
+13 -9
View File
@@ -148,21 +148,22 @@ serve(async (req) => {
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers({ perPage: 1000 });
const authUser = authUsers?.find((u: { email?: string }) => u.email === telegramEmail);
// Resolve P2P user ID: prefer p2p_user_id (citizen/visa UUID, same as pwap/web)
// fallback to tg_users.id (legacy Supabase Auth UUID)
const { data: tgUser, error: tgUserError } = await supabase
.from('tg_users')
.select('id, p2p_user_id')
.eq('telegram_id', telegramId)
.single();
if (!authUser) {
if (tgUserError || !tgUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const userId = authUser.id;
const userId = tgUser.p2p_user_id ?? tgUser.id;
// Verify user is a party to this trade
const { data: trade, error: tradeError } = await supabase
@@ -248,7 +249,10 @@ serve(async (req) => {
// Mark unread messages as read for this user
// (messages sent by the OTHER party that haven't been read yet)
const unreadMessageIds = (messages || [])
.filter((m: { sender_id: string; is_read: boolean; id: string }) => m.sender_id !== userId && !m.is_read)
.filter(
(m: { sender_id: string; is_read: boolean; id: string }) =>
m.sender_id !== userId && !m.is_read
)
.map((m: { id: string }) => m.id);
if (unreadMessageIds.length > 0) {
@@ -162,44 +162,55 @@ async function sendTokens(
return new Promise((resolve) => {
let txHash: string;
tx.signAndSend(hotWallet, { nonce }, (result: { txHash: { toHex: () => string }; status: { isInBlock: boolean; asInBlock: { toHex: () => string }; isFinalized: boolean }; dispatchError: { isModule: boolean; asModule: unknown; toString: () => string } | undefined; isError: boolean }) => {
txHash = result.txHash.toHex();
tx.signAndSend(
hotWallet,
{ nonce },
(result: {
txHash: { toHex: () => string };
status: { isInBlock: boolean; asInBlock: { toHex: () => string }; isFinalized: boolean };
dispatchError:
| { isModule: boolean; asModule: unknown; toString: () => string }
| undefined;
isError: boolean;
}) => {
txHash = result.txHash.toHex();
if (result.status.isInBlock) {
console.log(`TX in block: ${result.status.asInBlock.toHex()}`);
}
if (result.status.isInBlock) {
console.log(`TX in block: ${result.status.asInBlock.toHex()}`);
}
if (result.status.isFinalized) {
const dispatchError = result.dispatchError;
if (result.status.isFinalized) {
const dispatchError = result.dispatchError;
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
resolve({
success: false,
txHash,
error: `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`,
});
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
resolve({
success: false,
txHash,
error: `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`,
});
} else {
resolve({
success: false,
txHash,
error: dispatchError.toString(),
});
}
} else {
resolve({
success: false,
txHash,
error: dispatchError.toString(),
});
resolve({ success: true, txHash });
}
} else {
resolve({ success: true, txHash });
}
if (result.isError) {
resolve({
success: false,
txHash,
error: 'Transaction failed',
});
}
}
if (result.isError) {
resolve({
success: false,
txHash,
error: 'Transaction failed',
});
}
}).catch((error: Error) => {
).catch((error: Error) => {
resolve({ success: false, error: error.message });
});
@@ -250,18 +261,18 @@ serve(async (req) => {
if (_mainToken) botTokens.push(_mainToken);
if (_krdToken) botTokens.push(_krdToken);
if (botTokens.length === 0) {
return new Response(
JSON.stringify({ success: false, error: 'Server configuration error' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
return new Response(JSON.stringify({ success: false, error: 'Server configuration error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Validate session token
if (!sessionToken) {
return new Response(
JSON.stringify({ success: false, error: 'Missing session token' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
return new Response(JSON.stringify({ success: false, error: 'Missing session token' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
let telegramId: number | null = null;
@@ -281,21 +292,22 @@ serve(async (req) => {
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const serviceClient = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user for telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: existingUsers },
} = await serviceClient.auth.admin.listUsers({ perPage: 1000 });
const authUser = existingUsers?.find((u: { email?: string }) => u.email === telegramEmail);
// Resolve P2P user ID: prefer p2p_user_id (citizen/visa UUID, same as pwap/web)
// fallback to tg_users.id (legacy Supabase Auth UUID)
const { data: tgUser, error: tgUserError } = await serviceClient
.from('tg_users')
.select('id, p2p_user_id')
.eq('telegram_id', telegramId)
.single();
if (!authUser) {
if (tgUserError || !tgUser) {
return new Response(
JSON.stringify({ success: false, error: 'User not found. Please deposit first.' }),
{ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
const userId = authUser.id;
const userId = tgUser.p2p_user_id ?? tgUser.id;
// Validate input
if (!token || !amount || !walletAddress) {
+57 -37
View File
@@ -142,10 +142,13 @@ serve(async (req) => {
const validActions = ['mark_paid', 'confirm', 'cancel', 'rate'];
if (!validActions.includes(action)) {
return new Response(JSON.stringify({ error: `Invalid action. Must be one of: ${validActions.join(', ')}` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
return new Response(
JSON.stringify({ error: `Invalid action. Must be one of: ${validActions.join(', ')}` }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Create Supabase admin client (bypasses RLS)
@@ -153,21 +156,22 @@ serve(async (req) => {
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get auth user ID for this telegram user
const telegramEmail = `telegram_${telegramId}@pezkuwichain.io`;
const {
data: { users: authUsers },
} = await supabase.auth.admin.listUsers({ perPage: 1000 });
const authUser = authUsers?.find((u: { email?: string }) => u.email === telegramEmail);
// Resolve P2P user ID: prefer p2p_user_id (citizen/visa UUID, same as pwap/web)
// fallback to tg_users.id (legacy Supabase Auth UUID)
const { data: tgUser, error: tgUserError } = await supabase
.from('tg_users')
.select('id, p2p_user_id')
.eq('telegram_id', telegramId)
.single();
if (!authUser) {
if (tgUserError || !tgUser) {
return new Response(JSON.stringify({ error: 'User not found. Please authenticate first.' }), {
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const userId = authUser.id;
const userId = tgUser.p2p_user_id ?? tgUser.id;
// Fetch the trade
const { data: trade, error: tradeError } = await supabase
@@ -205,10 +209,15 @@ serve(async (req) => {
// Trade must be in pending status
if (trade.status !== 'pending') {
return new Response(JSON.stringify({ error: `Cannot mark paid: trade status is '${trade.status}', expected 'pending'` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
return new Response(
JSON.stringify({
error: `Cannot mark paid: trade status is '${trade.status}', expected 'pending'`,
}),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
const now = new Date().toISOString();
@@ -256,18 +265,26 @@ serve(async (req) => {
else if (action === 'confirm') {
// Only seller can confirm
if (trade.seller_id !== userId) {
return new Response(JSON.stringify({ error: 'Only the seller can confirm and release crypto' }), {
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
return new Response(
JSON.stringify({ error: 'Only the seller can confirm and release crypto' }),
{
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Trade must be in payment_sent status
if (trade.status !== 'payment_sent') {
return new Response(JSON.stringify({ error: `Cannot confirm: trade status is '${trade.status}', expected 'payment_sent'` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
return new Response(
JSON.stringify({
error: `Cannot confirm: trade status is '${trade.status}', expected 'payment_sent'`,
}),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
// Get offer details to know the token
@@ -308,7 +325,8 @@ serve(async (req) => {
);
}
const releaseResponse = typeof releaseResult === 'string' ? JSON.parse(releaseResult) : releaseResult;
const releaseResponse =
typeof releaseResult === 'string' ? JSON.parse(releaseResult) : releaseResult;
if (releaseResponse && !releaseResponse.success) {
return new Response(
@@ -337,7 +355,9 @@ serve(async (req) => {
if (updateError) {
console.error('Confirm trade update error:', updateError);
return new Response(
JSON.stringify({ error: 'Escrow released but failed to update trade status: ' + updateError.message }),
JSON.stringify({
error: 'Escrow released but failed to update trade status: ' + updateError.message,
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
@@ -365,10 +385,13 @@ serve(async (req) => {
else if (action === 'cancel') {
// Trade must be in pending or payment_sent status to cancel
if (!['pending', 'payment_sent'].includes(trade.status)) {
return new Response(JSON.stringify({ error: `Cannot cancel: trade status is '${trade.status}'` }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
return new Response(
JSON.stringify({ error: `Cannot cancel: trade status is '${trade.status}'` }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
}
const cancelReason = payload?.reason || 'Cancelled by user';
@@ -441,13 +464,10 @@ serve(async (req) => {
const result = typeof rpcResult === 'string' ? JSON.parse(rpcResult) : rpcResult;
if (result && !result.success) {
return new Response(
JSON.stringify({ error: result.error || 'Failed to cancel trade' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
);
return new Response(JSON.stringify({ error: result.error || 'Failed to cancel trade' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Fetch updated trade
@@ -0,0 +1,9 @@
-- Add p2p_user_id to tg_users for unified P2P identity across platforms
--
-- p2p_user_id = UUID v5 derived from citizen/visa number (same as pwap/web)
-- When a Telegram user links their wallet, this is populated.
-- Edge functions use: p2p_user_id ?? id (fallback keeps old behaviour)
ALTER TABLE tg_users ADD COLUMN IF NOT EXISTS p2p_user_id UUID;
CREATE INDEX IF NOT EXISTS idx_tg_users_p2p_user_id ON tg_users(p2p_user_id);