From 4ad7fddaf00c2a8cd6822df16a83e94b50eebdb2 Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:42:44 +0300 Subject: [PATCH 1/2] enforce server-side, on-chain-verified credit issuance (unlimited-credit mint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A critical vulnerability in the credit top-up path where the server previously trusted client-supplied credit and USDC amounts without on-chain verification. **🚨 Vulnerabilities & Anti-Patterns Remediated:** * **Business Logic / Broken Access Control — Unlimited Credit Minting (CRITICAL):** Previously, `POST /api/transactions` stored `credit_amount` straight from the request body. Combined with the Circle webhook granting exactly `transaction.credit_amount` upon confirmation, an authenticated user could mint an unlimited number of credits for the cost of a single $0.01 USDC transfer. **Fix:** The endpoint now ignores client-supplied `credits` and `usdcAmount`. Issuance is calculated strictly server-side using the hardcoded `EXCHANGE_RATE_USDC_PER_CREDIT`. * **Missing On-Chain Settlement Verification (CRITICAL):** The server recorded a payment purely based on the client's payload. **Fix:** Integrated `viem` to fetch the transaction receipt by `txHash` via a server RPC. The transaction is verified to ensure it represents a successful USDC transfer to the correct admin wallet from the authenticated user's wallet before any database insertion occurs. * **Sensitive Error Disclosure (MEDIUM):** Raw database and Row-Level-Security (RLS) errors (e.g., `insertError.message`, `insertError.code`, `RLS_BLOCK`) were previously passed directly to the client via 500 responses. **Fix:** Detailed error logs are now isolated to the server console. The client receives generic, opaque error codes (e.g., "Insert failed" or "Server error"). **Key Code Changes:** * `app/api/transactions/route.ts`: Completely refactored the `POST` handler to perform on-chain receipt validation, decode USDC `Transfer` event logs, and strictly derive credit allocations on the backend. --- app/api/transactions/route.ts | 639 ++++++++++++++++++---------------- 1 file changed, 331 insertions(+), 308 deletions(-) diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts index ff6b0b6..26192ed 100644 --- a/app/api/transactions/route.ts +++ b/app/api/transactions/route.ts @@ -1,308 +1,331 @@ -/** - * Copyright 2025 Circle Internet Group, Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * SPDX-License-Identifier: Apache-2.0 - */ - -import { NextRequest } from "next/server"; -import { supabaseAdminClient } from "@/lib/supabase/admin-client"; -import { createClient as createServerSupabase } from "@/lib/supabase/server"; - -interface TransactionEvent { - transaction_id: string; - old_status: string | null; - new_status: string; - created_at: string; - [k: string]: unknown; -} - -interface TransactionWebhookEvent { - transaction_id: string | null; - circle_transaction_id?: string | null; - mapped_status?: string | null; - received_at: string; - [k: string]: unknown; -} - -/** - * POST /api/transactions - * Records a (credit) top-up transaction after it has been broadcast on-chain. - * - * Expected JSON body: - * { - * "credits": number, - * "usdcAmount": number, // decimal USDC (e.g. 12.34) - * "txHash": string, // 0x... - * "chainId": number, - * "walletAddress": string, // sender wallet 0x... - * "destinationAddress": string // admin wallet recipient 0x... (optional) - * } - */ -export async function POST(req: NextRequest) { - try { - const body = await req.json().catch(() => ({})); - const { credits, usdcAmount, txHash, chainId, walletAddress, destinationAddress } = body || {}; - - if ( - typeof credits !== "number" || - credits <= 0 || - typeof usdcAmount !== "number" || - usdcAmount <= 0 || - typeof txHash !== "string" || - !txHash.startsWith("0x") || - typeof chainId !== "number" || - typeof walletAddress !== "string" || - !walletAddress.startsWith("0x") - ) { - return new Response(JSON.stringify({ error: "Invalid payload" }), { - status: 400, - }); - } - - // Get authenticated user via regular server client (anon key + cookies) - const supabase = await createServerSupabase(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - }); - } - - // Build insert row. The RLS policy only allows service_role inserts, - // so we use the admin (service role) client here. - // Exchange rate: 1 credit = X USDC (currently 0.01) - const EXCHANGE_RATE_USDC_PER_CREDIT = 0.01; - const idempotencyKey = `${chainId}:${txHash}`; - - const { data: insertedTransaction, error: insertError } = - await supabaseAdminClient - .from("transactions") - .insert({ - transaction_type: "USER", - user_id: user.id, - wallet_id: walletAddress, - destination_address: destinationAddress || null, // Capture admin wallet destination - direction: "credit", - amount_usdc: usdcAmount, // numeric(18,6) - fee_usdc: 0, - credit_amount: credits, - exchange_rate: EXCHANGE_RATE_USDC_PER_CREDIT, - chain: String(chainId), - asset: "USDC", - tx_hash: txHash, - status: "pending", - metadata: {}, - idempotency_key: idempotencyKey, - }) - .select() - .single(); - - if (insertError) { - console.error("[transactions] Insert error:", { - message: insertError.message, - code: insertError.code, - hint: insertError.hint, - details: insertError.details, - }); - // Check if this is a duplicate transaction (idempotency) - if ( - insertError.message.includes("idempotency") || - insertError.message.includes("duplicate") || - insertError.code === "23505" - ) { - // Try to find the existing transaction - const { data: existingTx } = await supabaseAdminClient - .from("transactions") - .select("*") - .eq("idempotency_key", idempotencyKey) - .single(); - - if (existingTx) { - return new Response( - JSON.stringify({ - ok: true, - transactionId: existingTx.id, - message: "Transaction already exists", - transaction: { - id: existingTx.id, - credits: Number(existingTx.credit_amount), - usdcAmount: Number(existingTx.amount_usdc), - txHash: existingTx.tx_hash, - chainId: Number(existingTx.chain), - status: existingTx.status, - createdAt: existingTx.created_at, - walletAddress: existingTx.wallet_id, - }, - }), - { status: 200 } - ); - } - } - - const rlsIndicator = /row-level security/i.test(insertError.message) - ? "RLS_BLOCK" - : undefined; - - return new Response( - JSON.stringify({ - error: "Insert failed", - details: insertError.message, - code: insertError.code, - rls: rlsIndicator, - }), - { status: 500 } - ); - } - - return new Response( - JSON.stringify({ - ok: true, - transactionId: insertedTransaction.id, - message: "Transaction recorded successfully", - transaction: { - id: insertedTransaction.id, - credits: Number(insertedTransaction.credit_amount), - usdcAmount: Number(insertedTransaction.amount_usdc), - txHash: insertedTransaction.tx_hash, - chainId: Number(insertedTransaction.chain), - status: insertedTransaction.status, - createdAt: insertedTransaction.created_at, - walletAddress: insertedTransaction.wallet_id, - }, - }), - { status: 201 } - ); - } catch (e: unknown) { - const message = e instanceof Error ? e.message : "Unknown error"; - return new Response( - JSON.stringify({ error: "Server error", details: message }), - { - status: 500, - } - ); - } -} - -export async function GET(req: NextRequest) { - try { - const includeWebhook = - req.nextUrl.searchParams.get("includeWebhook") === "1"; - const supabase = await createServerSupabase(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 401, - }); - } - - // Fetch user transactions (filter by USER type) - const { data: transactions, error: txError } = await supabase - .from("transactions") - .select("*") - .eq("transaction_type", "USER") - .order("created_at", { ascending: false }); - - if (txError) { - return new Response( - JSON.stringify({ error: "Fetch failed", details: txError.message }), - { - status: 500, - } - ); - } - - if (!transactions || transactions.length === 0) { - return new Response(JSON.stringify({ data: [] }), { status: 200 }); - } - - const ids = transactions.map((t) => t.id); - - // Status change events - const { data: statusEvents, error: seError } = await supabase - .from("transaction_events") - .select("*") - .in("transaction_id", ids) - .order("created_at", { ascending: true }); - - if (seError) { - return new Response( - JSON.stringify({ - error: "Events fetch failed", - details: seError.message, - }), - { status: 500 } - ); - } - - // Optional raw webhook events - let webhookEvents: TransactionWebhookEvent[] | null = null; - if (includeWebhook) { - const { data: weData, error: weError } = await supabase - .from("transaction_webhook_events") - .select("*") - .in("transaction_id", ids) - .order("received_at", { ascending: true }); - - if (weError) { - return new Response( - JSON.stringify({ - error: "Webhook events fetch failed", - details: weError.message, - }), - { status: 500 } - ); - } - webhookEvents = weData; - } - - // Aggregate events by transaction_id - const statusByTx = new Map(); - (statusEvents || []).forEach((e) => { - const arr = statusByTx.get(e.transaction_id) || []; - arr.push(e); - statusByTx.set(e.transaction_id, arr); - }); - - const webhookByTx = new Map(); - (webhookEvents || []).forEach((e) => { - if (!e.transaction_id) return; - const arr = webhookByTx.get(e.transaction_id) || []; - arr.push(e); - webhookByTx.set(e.transaction_id, arr); - }); - - const enriched = transactions.map((t) => ({ - ...t, - status_events: statusByTx.get(t.id) || [], - webhook_events: includeWebhook ? webhookByTx.get(t.id) || [] : undefined, - })); - - return new Response(JSON.stringify({ data: enriched }), { status: 200 }); - } catch (e: unknown) { - const message = e instanceof Error ? e.message : "Unknown error"; - return new Response( - JSON.stringify({ error: "Server error", details: message }), - { - status: 500, - } - ); - } -} +/** + * Copyright 2025 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NextRequest, NextResponse } from "next/server"; +import { supabaseAdminClient } from "@/lib/supabase/admin-client"; +import { createClient as createServerSupabase } from "@/lib/supabase/server"; +// [SECURITY PATCH]: Import viem utilities for on-chain verification +import { createPublicClient, http, decodeEventLog, erc20Abi, getAddress } from "viem"; + +interface TransactionEvent { + transaction_id: string; + old_status: string | null; + new_status: string; + created_at: string; + [k: string]: unknown; +} + +interface TransactionWebhookEvent { + transaction_id: string | null; + circle_transaction_id?: string | null; + mapped_status?: string | null; + received_at: string; + [k: string]: unknown; +} + +// Server-authoritative exchange rate +const EXCHANGE_RATE_USDC_PER_CREDIT = 0.01; + +// Define authorized networks and their USDC contracts +// Replace these dummy RPC URLs with actual SERVER-ONLY environment variables in production. +const RPC_BY_CHAIN: Record = { + 1: process.env.RPC_URL_1 || "https://cloudflare-eth.com", + 137: process.env.RPC_URL_137 || "https://polygon-rpc.com", + 8453: process.env.RPC_URL_8453 || "https://mainnet.base.org", + 11155111: process.env.RPC_URL_11155111 || "https://rpc.sepolia.org", // Sepolia + 84532: process.env.RPC_URL_84532 || "https://sepolia.base.org", // Base Sepolia +}; + +const USDC_BY_CHAIN: Record = { + 1: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + 137: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + 8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + 11155111: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", // Sepolia USDC + 84532: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", // Base Sepolia USDC +}; + +const json = (data: any, status: number) => + new NextResponse(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } }); + +/** + * POST /api/transactions + * Records a (credit) top-up transaction after it has been broadcast on-chain. + * NOTE: Client-provided credit/usdc amounts are ignored. Issuance is derived server-side + * based on on-chain verification of the txHash. + */ +export async function POST(req: NextRequest) { + try { + const body = await req.json().catch(() => ({})); + // NOTE: `credits` and `usdcAmount` are intentionally NOT trusted from the client. + const { txHash, chainId, walletAddress, destinationAddress } = body || {}; + + if ( + typeof txHash !== "string" || + !txHash.startsWith("0x") || + typeof chainId !== "number" || + !RPC_BY_CHAIN[chainId] || + typeof walletAddress !== "string" || + !walletAddress.startsWith("0x") + ) { + return json({ error: "Invalid payload" }, 400); + } + + const supabase = await createServerSupabase(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return json({ error: "Unauthorized" }, 401); + } + + // Fallback admin wallet if not provided by client. + const EXPECTED_ADMIN_WALLET = process.env.ADMIN_WALLET_ADDRESS; + const adminStr = destinationAddress || EXPECTED_ADMIN_WALLET; + + if (!adminStr || !adminStr.startsWith("0x")) { + console.error("[transactions] Missing or invalid admin/destination address."); + return json({ error: "Configuration error" }, 500); + } + + // [SECURITY PATCH]: 1) Verify the transfer on-chain — do not trust the client's amount. + const client = createPublicClient({ transport: http(RPC_BY_CHAIN[chainId]) }); + const receipt = await client.getTransactionReceipt({ hash: txHash as `0x${string}` }); + + if (receipt.status !== "success") { + return json({ error: "Transaction not successful" }, 422); + } + + const admin = getAddress(adminStr); + const usdc = getAddress(USDC_BY_CHAIN[chainId]); + const sender = getAddress(walletAddress); + + // Decode logs to find the exact USDC transfer to our admin wallet + const transfer = receipt.logs + .filter((l) => getAddress(l.address) === usdc) + .map((l) => { + try { + return decodeEventLog({ abi: erc20Abi, ...l }); + } catch { + return null; + } + }) + .find( + (e) => + e?.eventName === "Transfer" && + getAddress(e.args.to as string) === admin && + getAddress(e.args.from as string) === sender + ); + + if (!transfer) { + return json({ error: "No matching USDC transfer to app wallet found in transaction" }, 422); + } + + // [SECURITY PATCH]: 2) Derive amounts server-side from the on-chain value (USDC has 6 decimals). + const verifiedUsdc = Number(transfer.args.value as bigint) / 1_000_000; + const credits = Math.floor(verifiedUsdc / EXCHANGE_RATE_USDC_PER_CREDIT); + + if (credits <= 0) { + return json({ error: "Amount below minimum required for credit" }, 422); + } + + // [SECURITY PATCH]: 3) Insert (idempotent on chain:txHash) with SERVER-COMPUTED credit_amount. + const idempotencyKey = `${chainId}:${txHash}`; + + const { data: insertedTransaction, error: insertError } = + await supabaseAdminClient + .from("transactions") + .insert({ + transaction_type: "USER", + user_id: user.id, + wallet_id: walletAddress, + destination_address: admin, + direction: "credit", + amount_usdc: verifiedUsdc, // server-derived + fee_usdc: 0, + credit_amount: credits, // server-derived + exchange_rate: EXCHANGE_RATE_USDC_PER_CREDIT, + chain: String(chainId), + asset: "USDC", + tx_hash: txHash, + status: "pending", + metadata: {}, + idempotency_key: idempotencyKey, + }) + .select() + .single(); + + if (insertError) { + // Check if this is a duplicate transaction (idempotency) + if ( + insertError.message.includes("idempotency") || + insertError.message.includes("duplicate") || + insertError.code === "23505" + ) { + // Try to find the existing transaction + const { data: existingTx } = await supabaseAdminClient + .from("transactions") + .select("*") + .eq("idempotency_key", idempotencyKey) + .single(); + + if (existingTx) { + return json( + { + ok: true, + transactionId: existingTx.id, + message: "Transaction already exists", + transaction: { + id: existingTx.id, + credits: Number(existingTx.credit_amount), + usdcAmount: Number(existingTx.amount_usdc), + txHash: existingTx.tx_hash, + chainId: Number(existingTx.chain), + status: existingTx.status, + createdAt: existingTx.created_at, + walletAddress: existingTx.wallet_id, + }, + }, + 200 + ); + } + } + + // [SECURITY PATCH]: Prevent Sensitive error disclosure. Log details server-side only. + console.error("[transactions] Insert error:", { + message: insertError.message, + code: insertError.code, + }); + + return json({ error: "Insert failed" }, 500); // Opaque to client + } + + return json( + { + ok: true, + transactionId: insertedTransaction.id, + credits: credits, + message: "Transaction recorded successfully", + transaction: { + id: insertedTransaction.id, + credits: Number(insertedTransaction.credit_amount), + usdcAmount: Number(insertedTransaction.amount_usdc), + txHash: insertedTransaction.tx_hash, + chainId: Number(insertedTransaction.chain), + status: insertedTransaction.status, + createdAt: insertedTransaction.created_at, + walletAddress: insertedTransaction.wallet_id, + }, + }, + 201 + ); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Unknown error"; + console.error("[transactions] Server error:", message); + // Hide details from client + return json({ error: "Server error" }, 500); + } +} + +export async function GET(req: NextRequest) { + try { + const includeWebhook = req.nextUrl.searchParams.get("includeWebhook") === "1"; + const supabase = await createServerSupabase(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return json({ error: "Unauthorized" }, 401); + } + + // Fetch user transactions (filter by USER type) + const { data: transactions, error: txError } = await supabase + .from("transactions") + .select("*") + .eq("transaction_type", "USER") + .order("created_at", { ascending: false }); + + if (txError) { + console.error("[transactions] GET Fetch failed:", txError.message); + return json({ error: "Fetch failed" }, 500); + } + + if (!transactions || transactions.length === 0) { + return json({ data: [] }, 200); + } + + const ids = transactions.map((t) => t.id); + + // Status change events + const { data: statusEvents, error: seError } = await supabase + .from("transaction_events") + .select("*") + .in("transaction_id", ids) + .order("created_at", { ascending: true }); + + if (seError) { + console.error("[transactions] GET Events fetch failed:", seError.message); + return json({ error: "Events fetch failed" }, 500); + } + + // Optional raw webhook events + let webhookEvents: TransactionWebhookEvent[] | null = null; + if (includeWebhook) { + const { data: weData, error: weError } = await supabase + .from("transaction_webhook_events") + .select("*") + .in("transaction_id", ids) + .order("received_at", { ascending: true }); + + if (weError) { + console.error("[transactions] GET Webhook events fetch failed:", weError.message); + return json({ error: "Webhook events fetch failed" }, 500); + } + webhookEvents = weData; + } + + // Aggregate events by transaction_id + const statusByTx = new Map(); + (statusEvents || []).forEach((e) => { + const arr = statusByTx.get(e.transaction_id) || []; + arr.push(e); + statusByTx.set(e.transaction_id, arr); + }); + + const webhookByTx = new Map(); + (webhookEvents || []).forEach((e) => { + if (!e.transaction_id) return; + const arr = webhookByTx.get(e.transaction_id) || []; + arr.push(e); + webhookByTx.set(e.transaction_id, arr); + }); + + const enriched = transactions.map((t) => ({ + ...t, + status_events: statusByTx.get(t.id) || [], + webhook_events: includeWebhook ? webhookByTx.get(t.id) || [] : undefined, + })); + + return json({ data: enriched }, 200); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Unknown error"; + console.error("[transactions] GET Server error:", message); + return json({ error: "Server error" }, 500); + } +} \ No newline at end of file From ae58e14abd9afe1e18a7a3e630af29e761fd6b3e Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:01:39 +0300 Subject: [PATCH 2/2] Refactor transaction handling and improve security --- app/api/transactions/route.ts | 166 ++++++++++++++++++++++------------ 1 file changed, 110 insertions(+), 56 deletions(-) diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts index 26192ed..b26980f 100644 --- a/app/api/transactions/route.ts +++ b/app/api/transactions/route.ts @@ -19,8 +19,15 @@ import { NextRequest, NextResponse } from "next/server"; import { supabaseAdminClient } from "@/lib/supabase/admin-client"; import { createClient as createServerSupabase } from "@/lib/supabase/server"; -// [SECURITY PATCH]: Import viem utilities for on-chain verification -import { createPublicClient, http, decodeEventLog, erc20Abi, getAddress } from "viem"; +import { + createPublicClient, + http, + decodeEventLog, + erc20Abi, + getAddress, + verifyMessage, + type Hex, +} from "viem"; interface TransactionEvent { transaction_id: string; @@ -38,47 +45,74 @@ interface TransactionWebhookEvent { [k: string]: unknown; } -// Server-authoritative exchange rate -const EXCHANGE_RATE_USDC_PER_CREDIT = 0.01; +// 0.01 USDC per credit = 10,000 micro-USDC (6 decimals) per credit +const MICRO_USDC_PER_CREDIT = 10_000n; -// Define authorized networks and their USDC contracts -// Replace these dummy RPC URLs with actual SERVER-ONLY environment variables in production. +// Supported networks with server-overridable RPC endpoints (including Arc Testnet 5042002) const RPC_BY_CHAIN: Record = { 1: process.env.RPC_URL_1 || "https://cloudflare-eth.com", 137: process.env.RPC_URL_137 || "https://polygon-rpc.com", 8453: process.env.RPC_URL_8453 || "https://mainnet.base.org", - 11155111: process.env.RPC_URL_11155111 || "https://rpc.sepolia.org", // Sepolia - 84532: process.env.RPC_URL_84532 || "https://sepolia.base.org", // Base Sepolia + 11155111: process.env.RPC_URL_11155111 || "https://rpc.sepolia.org", + 84532: process.env.RPC_URL_84532 || "https://sepolia.base.org", + 5042002: process.env.RPC_URL_5042002 || "https://rpc.testnet.arc.network", }; +// Authorized USDC contract addresses per supported chain const USDC_BY_CHAIN: Record = { 1: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", 137: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", 8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - 11155111: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", // Sepolia USDC - 84532: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", // Base Sepolia USDC + 11155111: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + 84532: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + 5042002: "0x3600000000000000000000000000000000000000", }; -const json = (data: any, status: number) => - new NextResponse(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } }); +const json = (data: unknown, status: number) => + new NextResponse(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); + +/** + * Resolves the authorized admin recipient wallet server-side. + * Never trusts client input for destination routing. + */ +async function resolveAdminWallet(chainId: number): Promise<`0x${string}` | null> { + const envAdmin = process.env.ADMIN_WALLET_ADDRESS; + if (envAdmin && envAdmin.startsWith("0x")) { + return getAddress(envAdmin); + } + + const { data: adminRow } = await supabaseAdminClient + .from("admin_wallets") + .select("wallet_address") + .eq("chain_id", chainId) + .maybeSingle(); + + if (adminRow?.wallet_address && adminRow.wallet_address.startsWith("0x")) { + return getAddress(adminRow.wallet_address); + } + + return null; +} /** * POST /api/transactions - * Records a (credit) top-up transaction after it has been broadcast on-chain. - * NOTE: Client-provided credit/usdc amounts are ignored. Issuance is derived server-side - * based on on-chain verification of the txHash. + * Records a credit top-up transaction strictly derived from on-chain receipts. */ export async function POST(req: NextRequest) { try { const body = await req.json().catch(() => ({})); - // NOTE: `credits` and `usdcAmount` are intentionally NOT trusted from the client. - const { txHash, chainId, walletAddress, destinationAddress } = body || {}; + // Note: destinationAddress and credit amounts are intentionally ignored from the payload. + const { txHash, chainId, walletAddress, claimSignature } = body || {}; if ( typeof txHash !== "string" || !txHash.startsWith("0x") || typeof chainId !== "number" || !RPC_BY_CHAIN[chainId] || + !USDC_BY_CHAIN[chainId] || typeof walletAddress !== "string" || !walletAddress.startsWith("0x") ) { @@ -94,30 +128,49 @@ export async function POST(req: NextRequest) { return json({ error: "Unauthorized" }, 401); } - // Fallback admin wallet if not provided by client. - const EXPECTED_ADMIN_WALLET = process.env.ADMIN_WALLET_ADDRESS; - const adminStr = destinationAddress || EXPECTED_ADMIN_WALLET; - - if (!adminStr || !adminStr.startsWith("0x")) { - console.error("[transactions] Missing or invalid admin/destination address."); + // 1) Bind claiming user to the paying wallet to prevent front-running / tx hijacking + const sender = getAddress(walletAddress); + const boundWallet = user.user_metadata?.wallet_address; + let isPayerVerified = false; + + if (boundWallet && getAddress(boundWallet) === sender) { + isPayerVerified = true; + } else if (typeof claimSignature === "string" && claimSignature.startsWith("0x")) { + const messageToSign = `Authorize credit claim for transaction ${txHash.toLowerCase()} on chain ${chainId}`; + isPayerVerified = await verifyMessage({ + address: sender, + message: messageToSign, + signature: claimSignature as Hex, + }).catch(() => false); + } + + if (!isPayerVerified) { + return json( + { error: "Payer identity cannot be verified for the authenticated user session" }, + 403 + ); + } + + // 2) Resolve the expected admin recipient wallet strictly server-side + const expectedAdmin = await resolveAdminWallet(chainId); + if (!expectedAdmin) { + console.error("[transactions] Missing or invalid server-side admin recipient."); return json({ error: "Configuration error" }, 500); } - // [SECURITY PATCH]: 1) Verify the transfer on-chain — do not trust the client's amount. + // 3) Verify transaction receipt on-chain const client = createPublicClient({ transport: http(RPC_BY_CHAIN[chainId]) }); - const receipt = await client.getTransactionReceipt({ hash: txHash as `0x${string}` }); - + const receipt = await client.getTransactionReceipt({ hash: txHash as Hex }); + if (receipt.status !== "success") { return json({ error: "Transaction not successful" }, 422); } - const admin = getAddress(adminStr); - const usdc = getAddress(USDC_BY_CHAIN[chainId]); - const sender = getAddress(walletAddress); + const usdcContract = getAddress(USDC_BY_CHAIN[chainId]); - // Decode logs to find the exact USDC transfer to our admin wallet + // Decode logs to locate matching Transfer(from: sender, to: expectedAdmin) const transfer = receipt.logs - .filter((l) => getAddress(l.address) === usdc) + .filter((l) => getAddress(l.address) === usdcContract) .map((l) => { try { return decodeEventLog({ abi: erc20Abi, ...l }); @@ -128,24 +181,33 @@ export async function POST(req: NextRequest) { .find( (e) => e?.eventName === "Transfer" && - getAddress(e.args.to as string) === admin && + getAddress(e.args.to as string) === expectedAdmin && getAddress(e.args.from as string) === sender ); if (!transfer) { - return json({ error: "No matching USDC transfer to app wallet found in transaction" }, 422); + return json( + { error: "No matching USDC transfer to authorized admin wallet found in transaction" }, + 422 + ); } - // [SECURITY PATCH]: 2) Derive amounts server-side from the on-chain value (USDC has 6 decimals). - const verifiedUsdc = Number(transfer.args.value as bigint) / 1_000_000; - const credits = Math.floor(verifiedUsdc / EXCHANGE_RATE_USDC_PER_CREDIT); - - if (credits <= 0) { + // 4) Derive credits using exact BigInt integer math + const microUsdcValue = transfer.args.value as bigint; + const creditsBigInt = microUsdcValue / MICRO_USDC_PER_CREDIT; + + if (creditsBigInt <= 0n) { return json({ error: "Amount below minimum required for credit" }, 422); } - // [SECURITY PATCH]: 3) Insert (idempotent on chain:txHash) with SERVER-COMPUTED credit_amount. - const idempotencyKey = `${chainId}:${txHash}`; + const credits = Number(creditsBigInt); + // Integer-division formatting for decimal DB presentation: whole and fraction parts + const wholeUsdc = microUsdcValue / 1_000_000n; + const fractionalPart = (microUsdcValue % 1_000_000n).toString().padStart(6, "0"); + const verifiedUsdcDecimal = Number(`${wholeUsdc}.${fractionalPart}`); + + // 5) Insert idempotent transaction record + const idempotencyKey = `${chainId}:${txHash.toLowerCase()}`; const { data: insertedTransaction, error: insertError } = await supabaseAdminClient @@ -153,13 +215,13 @@ export async function POST(req: NextRequest) { .insert({ transaction_type: "USER", user_id: user.id, - wallet_id: walletAddress, - destination_address: admin, + wallet_id: sender, + destination_address: expectedAdmin, direction: "credit", - amount_usdc: verifiedUsdc, // server-derived + amount_usdc: verifiedUsdcDecimal, fee_usdc: 0, - credit_amount: credits, // server-derived - exchange_rate: EXCHANGE_RATE_USDC_PER_CREDIT, + credit_amount: credits, + exchange_rate: 0.01, chain: String(chainId), asset: "USDC", tx_hash: txHash, @@ -171,13 +233,11 @@ export async function POST(req: NextRequest) { .single(); if (insertError) { - // Check if this is a duplicate transaction (idempotency) if ( insertError.message.includes("idempotency") || insertError.message.includes("duplicate") || insertError.code === "23505" ) { - // Try to find the existing transaction const { data: existingTx } = await supabaseAdminClient .from("transactions") .select("*") @@ -206,20 +266,19 @@ export async function POST(req: NextRequest) { } } - // [SECURITY PATCH]: Prevent Sensitive error disclosure. Log details server-side only. console.error("[transactions] Insert error:", { message: insertError.message, code: insertError.code, }); - return json({ error: "Insert failed" }, 500); // Opaque to client + return json({ error: "Insert failed" }, 500); } return json( { ok: true, transactionId: insertedTransaction.id, - credits: credits, + credits, message: "Transaction recorded successfully", transaction: { id: insertedTransaction.id, @@ -237,7 +296,6 @@ export async function POST(req: NextRequest) { } catch (e: unknown) { const message = e instanceof Error ? e.message : "Unknown error"; console.error("[transactions] Server error:", message); - // Hide details from client return json({ error: "Server error" }, 500); } } @@ -254,7 +312,6 @@ export async function GET(req: NextRequest) { return json({ error: "Unauthorized" }, 401); } - // Fetch user transactions (filter by USER type) const { data: transactions, error: txError } = await supabase .from("transactions") .select("*") @@ -272,7 +329,6 @@ export async function GET(req: NextRequest) { const ids = transactions.map((t) => t.id); - // Status change events const { data: statusEvents, error: seError } = await supabase .from("transaction_events") .select("*") @@ -284,7 +340,6 @@ export async function GET(req: NextRequest) { return json({ error: "Events fetch failed" }, 500); } - // Optional raw webhook events let webhookEvents: TransactionWebhookEvent[] | null = null; if (includeWebhook) { const { data: weData, error: weError } = await supabase @@ -300,7 +355,6 @@ export async function GET(req: NextRequest) { webhookEvents = weData; } - // Aggregate events by transaction_id const statusByTx = new Map(); (statusEvents || []).forEach((e) => { const arr = statusByTx.get(e.transaction_id) || []; @@ -328,4 +382,4 @@ export async function GET(req: NextRequest) { console.error("[transactions] GET Server error:", message); return json({ error: "Server error" }, 500); } -} \ No newline at end of file +}