diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts index ff6b0b6..db2f399 100644 --- a/app/api/transactions/route.ts +++ b/app/api/transactions/route.ts @@ -17,8 +17,25 @@ */ import { NextRequest } from "next/server"; +import { + createPublicClient, + decodeEventLog, + erc20Abi, + getAddress, + http, + isAddress, + isHash, + recoverMessageAddress, + type Hash, + type Hex, +} from "viem"; import { supabaseAdminClient } from "@/lib/supabase/admin-client"; import { createClient as createServerSupabase } from "@/lib/supabase/server"; +import { getUsdcAddress } from "@/lib/wagmi/usdcAddresses"; +import { + buildTopupClaimMessage, + USDC_MICRO_PER_CREDIT, +} from "@/lib/credits/topup-claim"; interface TransactionEvent { transaction_id: string; @@ -36,58 +53,184 @@ interface TransactionWebhookEvent { [k: string]: unknown; } +/** Exchange rate stored on the row; 1 USDC = 1 credit (see USDC_MICRO_PER_CREDIT). */ +const EXCHANGE_RATE_USDC_PER_CREDIT = 1; + +const RPC_BY_CHAIN: Record = { + 1: process.env.RPC_URL_1, + 137: process.env.RPC_URL_137, + 8453: process.env.RPC_URL_8453, + 42161: process.env.RPC_URL_42161, + 10: process.env.RPC_URL_10, + 11155111: process.env.RPC_URL_11155111, + 84532: process.env.RPC_URL_84532, + 80002: process.env.RPC_URL_80002, + 421614: process.env.RPC_URL_421614, + 11155420: process.env.RPC_URL_11155420, + // Arc Testnet — primary demo chain in this sample app. + 5042002: process.env.RPC_URL_5042002 || "https://rpc.testnet.arc.network", +}; + +function json(data: unknown, status: number) { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function resolveAdminWalletAddress(): Promise<`0x${string}` | null> { + const fromEnv = process.env.ADMIN_WALLET_ADDRESS; + if (fromEnv && isAddress(fromEnv)) { + return getAddress(fromEnv); + } + + const { data, error } = await supabaseAdminClient + .from("admin_wallets") + .select("address") + .order("created_at", { ascending: true }) + .limit(1) + .maybeSingle(); + + if (error || !data?.address || !isAddress(data.address)) { + return null; + } + return getAddress(data.address); +} + /** * POST /api/transactions - * Records a (credit) top-up transaction after it has been broadcast on-chain. + * Records a credit top-up after on-chain USDC settlement. * - * Expected JSON body: + * Client-supplied `credits` / `usdcAmount` / `destinationAddress` are ignored. + * Amounts come from the Transfer log; the recipient is the server-side admin + * wallet; the payer must sign `buildTopupClaimMessage` so another session + * cannot claim the payment. + * + * Body: * { - * "credits": number, - * "usdcAmount": number, // decimal USDC (e.g. 12.34) - * "txHash": string, // 0x... + * "txHash": "0x...", * "chainId": number, - * "walletAddress": string, // sender wallet 0x... - * "destinationAddress": string // admin wallet recipient 0x... (optional) + * "claimSignature": "0x..." // personal_sign of buildTopupClaimMessage * } */ export async function POST(req: NextRequest) { try { const body = await req.json().catch(() => ({})); - const { credits, usdcAmount, txHash, chainId, walletAddress, destinationAddress } = body || {}; + const { txHash, chainId, claimSignature } = body || {}; if ( - typeof credits !== "number" || - credits <= 0 || - typeof usdcAmount !== "number" || - usdcAmount <= 0 || typeof txHash !== "string" || - !txHash.startsWith("0x") || + !isHash(txHash) || typeof chainId !== "number" || - typeof walletAddress !== "string" || - !walletAddress.startsWith("0x") + typeof claimSignature !== "string" || + !claimSignature.startsWith("0x") ) { - return new Response(JSON.stringify({ error: "Invalid payload" }), { - status: 400, - }); + return json({ error: "Invalid payload" }, 400); + } + + const rpcUrl = RPC_BY_CHAIN[chainId]; + const usdcAddress = getUsdcAddress(chainId); + if (!rpcUrl || !usdcAddress) { + return json({ error: "Unsupported chain" }, 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, + return json({ error: "Unauthorized" }, 401); + } + + const admin = await resolveAdminWalletAddress(); + if (!admin) { + console.error("[transactions] No admin destination wallet configured"); + return json({ error: "Configuration error" }, 500); + } + + const claimMessage = buildTopupClaimMessage(chainId, txHash); + let claimant: `0x${string}`; + try { + claimant = await recoverMessageAddress({ + message: claimMessage, + signature: claimSignature as Hex, + }); + } catch { + return json({ error: "Invalid claim signature" }, 401); + } + + const client = createPublicClient({ transport: http(rpcUrl) }); + + let receipt; + try { + receipt = await client.waitForTransactionReceipt({ + hash: txHash as Hash, + timeout: 60_000, + }); + } catch (err) { + console.error("[transactions] Receipt wait failed:", err); + return json( + { error: "Transaction receipt not available yet; retry shortly" }, + 408, + ); + } + + if (receipt.status !== "success") { + return json({ error: "Transaction not successful" }, 422); + } + + const usdc = getAddress(usdcAddress); + const transfer = receipt.logs + .filter((log) => { + try { + return getAddress(log.address) === usdc; + } catch { + return false; + } + }) + .map((log) => { + try { + return decodeEventLog({ + abi: erc20Abi, + data: log.data, + topics: log.topics, + }); + } catch { + return null; + } + }) + .find((event) => { + if (!event || event.eventName !== "Transfer") return false; + try { + const to = getAddress(event.args.to as string); + const from = getAddress(event.args.from as string); + return to === admin && from === claimant; + } catch { + return false; + } }); + + if (!transfer || transfer.eventName !== "Transfer") { + return json( + { + error: + "No matching USDC transfer from the claiming wallet to the app wallet", + }, + 422, + ); + } + + const value = transfer.args.value as bigint; + if (value < USDC_MICRO_PER_CREDIT) { + return json({ error: "Amount below minimum required for credit" }, 422); } - // 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 credits = Number(value / USDC_MICRO_PER_CREDIT); + const verifiedUsdc = + Number(value / 1_000_000n) + Number(value % 1_000_000n) / 1_000_000; + + const idempotencyKey = `${chainId}:${txHash.toLowerCase()}`; const { data: insertedTransaction, error: insertError } = await supabaseAdminClient @@ -95,10 +238,10 @@ export async function POST(req: NextRequest) { .insert({ transaction_type: "USER", user_id: user.id, - wallet_id: walletAddress, - destination_address: destinationAddress || null, // Capture admin wallet destination + wallet_id: claimant, + destination_address: admin, direction: "credit", - amount_usdc: usdcAmount, // numeric(18,6) + amount_usdc: verifiedUsdc, fee_usdc: 0, credit_amount: credits, exchange_rate: EXCHANGE_RATE_USDC_PER_CREDIT, @@ -113,19 +256,11 @@ export async function POST(req: NextRequest) { .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("*") @@ -133,8 +268,12 @@ export async function POST(req: NextRequest) { .single(); if (existingTx) { - return new Response( - JSON.stringify({ + // Only the original claimant's session may read back the row as ok. + if (existingTx.user_id !== user.id) { + return json({ error: "Transaction already claimed" }, 409); + } + return json( + { ok: true, transactionId: existingTx.id, message: "Transaction already exists", @@ -148,29 +287,21 @@ export async function POST(req: NextRequest) { createdAt: existingTx.created_at, walletAddress: existingTx.wallet_id, }, - }), - { status: 200 } + }, + 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 } - ); + console.error("[transactions] Insert error:", { + message: insertError.message, + code: insertError.code, + }); + return json({ error: "Insert failed" }, 500); } - return new Response( - JSON.stringify({ + return json( + { ok: true, transactionId: insertedTransaction.id, message: "Transaction recorded successfully", @@ -184,17 +315,13 @@ export async function POST(req: NextRequest) { createdAt: insertedTransaction.created_at, walletAddress: insertedTransaction.wallet_id, }, - }), - { status: 201 } + }, + 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, - } - ); + console.error("[transactions] Server error:", message); + return json({ error: "Server error" }, 500); } } @@ -225,7 +352,7 @@ export async function GET(req: NextRequest) { JSON.stringify({ error: "Fetch failed", details: txError.message }), { status: 500, - } + }, ); } @@ -248,7 +375,7 @@ export async function GET(req: NextRequest) { error: "Events fetch failed", details: seError.message, }), - { status: 500 } + { status: 500 }, ); } @@ -267,7 +394,7 @@ export async function GET(req: NextRequest) { error: "Webhook events fetch failed", details: weError.message, }), - { status: 500 } + { status: 500 }, ); } webhookEvents = weData; @@ -302,7 +429,7 @@ export async function GET(req: NextRequest) { JSON.stringify({ error: "Server error", details: message }), { status: 500, - } + }, ); } } diff --git a/components/wallet/purchase-credits-card.tsx b/components/wallet/purchase-credits-card.tsx index 4b565ef..4b58374 100644 --- a/components/wallet/purchase-credits-card.tsx +++ b/components/wallet/purchase-credits-card.tsx @@ -19,7 +19,12 @@ "use client"; import { useState, useMemo, useEffect } from "react"; -import { useAccount, useChainId, useWriteContract } from "wagmi"; +import { + useAccount, + useChainId, + useSignMessage, + useWriteContract, +} from "wagmi"; import { BaseError, erc20Abi } from "viem"; import { Button } from "@/components/ui/button"; import { @@ -37,6 +42,7 @@ import { toast } from "sonner"; import Image from "next/image"; import Link from "next/link"; import { TransactionConfirmationModal } from "@/components/wallet/transaction-confirmation-modal"; +import { buildTopupClaimMessage } from "@/lib/credits/topup-claim"; const USDC_PER_CREDIT = 1; const presetUsdcAmounts = [10, 25, 50, 100]; @@ -51,6 +57,7 @@ export function PurchaseCreditsCard() { isLoading: isBalanceLoading, } = useUsdcBalance(); const { writeContractAsync } = useWriteContract(); + const { signMessageAsync } = useSignMessage(); const [creditsToPurchase, setCreditsToPurchase] = useState(10); const [isSubmitting, setIsSubmitting] = useState(false); const [currentTransaction, setCurrentTransaction] = useState<{ @@ -157,19 +164,19 @@ export function PurchaseCreditsCard() { description: `Hash: ${txHash.slice(0, 10)}...`, }); - // Persist (fire-and-forget with basic handling) + // Prove control of the paying wallet so another session cannot claim this tx. + const claimSignature = await signMessageAsync({ + message: buildTopupClaimMessage(chainId, txHash), + }); + + // Server derives credits from the on-chain Transfer; client amounts are ignored. const res = await fetch("/api/transactions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - credits: creditsToPurchase, - usdcAmount: - Number((requiredUsdcMicro / 1_000_000n).toString()) + - Number(requiredUsdcMicro % 1_000_000n) / 1_000_000, txHash, chainId, - walletAddress: address, - destinationAddress: destination, // Include admin wallet destination + claimSignature, }), }); @@ -180,13 +187,22 @@ export function PurchaseCreditsCard() { }); } else { const responseData = await res.json(); + const recorded = responseData.transaction; + const recordedCredits = + typeof recorded?.credits === "number" + ? recorded.credits + : creditsToPurchase; + const recordedUsdc = + typeof recorded?.usdcAmount === "number" + ? recorded.usdcAmount + : Number((requiredUsdcMicro / 1_000_000n).toString()) + + Number(requiredUsdcMicro % 1_000_000n) / 1_000_000; // Create transaction object for confirmation modal const transaction = { id: responseData.transactionId || txHash, // Fallback to txHash if no ID returned - credits: creditsToPurchase, - usdcAmount: Number((requiredUsdcMicro / 1_000_000n).toString()) + - Number(requiredUsdcMicro % 1_000_000n) / 1_000_000, + credits: recordedCredits, + usdcAmount: recordedUsdc, txHash, chainId, status: "pending" as const, diff --git a/lib/credits/topup-claim.ts b/lib/credits/topup-claim.ts new file mode 100644 index 0000000..32510c1 --- /dev/null +++ b/lib/credits/topup-claim.ts @@ -0,0 +1,34 @@ +/** + * 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 + */ + +/** + * Shared claim-message format for credit top-ups. + * + * The paying wallet must sign this after the USDC transfer so credits cannot be + * claimed against someone else's on-chain payment by a different session. + */ +export function buildTopupClaimMessage(chainId: number, txHash: string): string { + return [ + "Arc Commerce credit claim", + `chainId:${chainId}`, + `txHash:${txHash.toLowerCase()}`, + ].join("\n"); +} + +/** Matches `PurchaseCreditsCard`: 1 USDC = 1 credit (6-decimal USDC). */ +export const USDC_MICRO_PER_CREDIT = 1_000_000n;