diff --git a/.env.example b/.env.example index 6c9abdd..9983ad0 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,13 @@ # Supabase -NEXT_PUBLIC_SUPABASE_URL=your-project-url -NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= # Circle -CIRCLE_API_KEY=your-circle-api-key -CIRCLE_ENTITY_SECRET=your-circle-entity-secret \ No newline at end of file +CIRCLE_API_KEY= +CIRCLE_ENTITY_SECRET= +CIRCLE_BLOCKCHAIN=ARC-TESTNET +CIRCLE_USDC_TOKEN_ID= + +# Misc +ADMIN_EMAIL=admin@admin.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bac372d..8895994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,8 @@ jobs: - name: Install Node uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 - - name: Install pnpm - uses: pnpm/action-setup@v4 - - name: Install dependencies - run: pnpm install + run: npm install scan: needs: lint-and-test diff --git a/.gitignore b/.gitignore index 73c1ffb..ede1bd4 100644 --- a/.gitignore +++ b/.gitignore @@ -38,8 +38,9 @@ yarn-error.log* # vercel .vercel +# supabase +supabase/.temp/ + # typescript *.tsbuildinfo next-env.d.ts - -.vscode/ diff --git a/README.md b/README.md index e63ee33..8c53397 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,85 @@ -# Circle Gateway Multichain USDC +# Arc Commerce -This sample app demonstrates how to integrate USDC as a payment method for purchasing credits on Arc. +This project demonstrates how to integrate USDC as a payment method for purchasing credits on Arc. -### Install dependencies +## Table of Contents -```bash -# Install dependencies -pnpm install +- [Clone and Run Locally](#clone-and-run-locally) +- [Environment Variables](#environment-variables) -# Configure environment variables -cp .env.example .env.local -``` +## Clone and Run Locally -Update `.env.local`: +1. **Clone and install dependencies:** -```ini -# Supabase -NEXT_PUBLIC_SUPABASE_URL=your-project-url -NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key + ```bash + git clone git@github.com:circlefin/arc-commerce.git + cd top-up + npm install + ``` -# Circle -CIRCLE_API_KEY=your-circle-api-key -CIRCLE_ENTITY_SECRET=your-circle-entity-secret -``` +2. **Set up environment variables:** -### Start Supabase + ```bash + cp .env.example .env.local + ``` -```bash -pnpx supabase start -``` + Then edit `.env.local` and fill in all required values (see [Environment Variables](#environment-variables) section below). -### Run Development Server +3. **Start Supabase locally** (requires Docker): -```bash -pnpm run dev -``` + ```bash + npx supabase start + npx supabase migration up + ``` -Visit [http://localhost:3000/wallet](http://localhost:3000/wallet) +4. **Start the development server:** -## How It Works + ```bash + npm run dev + ``` -### Unified Balance + The app will be available at [http://localhost:3000](http://localhost:3000/). The admin wallet will be automatically created on first startup. -When you deposit USDC to the Gateway Wallet, it becomes part of your unified balance accessible from any supported chain. The Gateway Wallet uses the same address on all chains: `0x0077777d7EBA4688BDeF3E311b846F25870A19B9` +5. **Set up Circle Webhooks:** -### Deposit Flow + In a separate terminal, start ngrok to expose your local server: -1. Approve Gateway Wallet to spend your USDC -2. Call `deposit()` to transfer USDC to Gateway -3. Balance becomes available across all chains after finalization + ```bash + ngrok http 3000 + ``` -### Cross-Chain Transfer Flow + Copy the HTTPS URL from ngrok (e.g., `https://your-ngrok-url.ngrok.io`) and add it to your Circle Console webhooks section: + - Navigate to Circle Console → Webhooks + - Add a new webhook endpoint: `https://your-ngrok-url.ngrok.io/api/circle/webhook` + - Keep ngrok running while developing to receive webhook events -1. Create and sign burn intent (EIP-712) -2. Submit to Gateway API for attestation -3. Call `gatewayMint()` on destination chain -4. USDC minted on destination +## Environment Variables -## Security Notes +Copy `.env.example` to `.env.local` and fill in the required values: -- This is a **testnet demonstration** only -- Private keys are processed server-side and never stored -- Never use mainnet private keys with this application -- Always use HTTPS in production -- Consider hardware wallet integration for production use +```bash +# Supabase +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= -## Resources +# Circle +CIRCLE_API_KEY= +CIRCLE_ENTITY_SECRET= +CIRCLE_BLOCKCHAIN=ARC-TESTNET +CIRCLE_USDC_TOKEN_ID= + +# Misc +ADMIN_EMAIL=admin@admin.com +``` -- [Circle Gateway Documentation](https://developers.circle.com/gateway) -- [Unified Balance Guide](https://developers.circle.com/gateway/howtos/create-unified-usdc-balance) -- [Circle Faucet](https://faucet.circle.com/) +| Variable | Scope | Purpose | +| ------------------------------------- | ----------- | ------------------------------------------------------------------------ | +| `NEXT_PUBLIC_SUPABASE_URL` | Public | Supabase project URL. | +| `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY` | Public | Supabase anonymous/public key. | +| `SUPABASE_SERVICE_ROLE_KEY` | Server-side | Service role for privileged writes (e.g., transaction inserts). | +| `CIRCLE_API_KEY` | Server-side | Used to fetch Circle webhook public keys for signature verification. | +| `CIRCLE_ENTITY_SECRET` | Server-side | Circle entity secret for wallet operations. | +| `CIRCLE_BLOCKCHAIN` | Server-side | Blockchain network identifier (e.g., "ARC-TESTNET"). | +| `CIRCLE_USDC_TOKEN_ID` | Server-side | USDC token ID for the specified blockchain. | +| `ADMIN_EMAIL` | Server-side | Admin user email address. | \ No newline at end of file diff --git a/app/api/circle/webhook/route.ts b/app/api/circle/webhook/route.ts new file mode 100644 index 0000000..150ec75 --- /dev/null +++ b/app/api/circle/webhook/route.ts @@ -0,0 +1,526 @@ +/** + * 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 crypto from "crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { supabaseAdminClient } from "@/lib/supabase/admin-client"; +import { circleDeveloperSdk } from "@/lib/circle/developer-controlled-wallets-client"; +import { convertToSmallestUnit } from "@/lib/utils/convert-to-smallest-unit"; +import { encodeFunctionData } from "viem"; +import type { Abi } from "viem"; +import { + CHAIN_IDS_TO_MESSAGE_TRANSMITTER, + CHAIN_IDS_TO_TOKEN_MESSENGER, + CHAIN_IDS_TO_USDC_ADDRESSES, + DESTINATION_DOMAINS, + SupportedChainId, +} from "@/lib/chains"; + +type CircleNotification = { + id?: string; + state?: string; + walletId?: string; + contractAddress?: string; + blockchain?: string; + amounts?: string[]; + txHash?: string; + [k: string]: unknown; +}; + +interface CircleWebhookPayload { + subscriptionId: string; + notificationId: string; + notificationType: string; + notification: CircleNotification; + timestamp: string; + version: number; + [k: string]: unknown; +} + +function mapCircleStateToStatus( + circleState: string | undefined +): "pending" | "confirmed" | "failed" | "complete" | null { + if (!circleState) return null; + const stateMap: Record = { + QUEUED: "pending", + SENT: "pending", + PENDING: "pending", + CONFIRMED: "confirmed", + COMPLETE: "complete", + FAILED: "failed", + }; + return stateMap[circleState] || null; +} + +function generateDedupeHash(bodyString: string): string { + return crypto.createHash("sha256").update(bodyString).digest("hex"); +} + +async function logWebhookEvent( + bodyString: string, + rawPayload: CircleWebhookPayload, + circleEventId: string | undefined, + circleTransactionId: string | undefined, + mappedStatus: string | null, + signatureValid: boolean +): Promise { + const dedupeHash = generateDedupeHash(bodyString); + try { + const { error } = await supabaseAdminClient + .from("transaction_webhook_events") + .insert({ + circle_event_id: circleEventId || null, + circle_transaction_id: circleTransactionId || null, + mapped_status: mappedStatus || null, + raw_payload: rawPayload, + signature_valid: signatureValid, + dedupe_hash: dedupeHash, + }); + + if (error) { + if (error.code === "23505") { + console.log(`Webhook event already processed (dedupe hash: ${dedupeHash.substring(0, 8)})`); + } else { + console.error("Failed to log webhook event:", error); + } + } + } catch (e) { + console.error("Error logging webhook event:", e); + } +} + +async function updateTransactionStatus(notification: CircleNotification) { + const mappedStatus = mapCircleStateToStatus(notification.state); + if (!mappedStatus) return; + + // Find the corresponding transaction in our database using the transaction hash. + const { data: creditTransactions, error: creditTxError } = await supabaseAdminClient + .from("transactions") + // Select the fields needed to update credits + .select("id, status, user_id, credit_amount") + .eq("tx_hash", notification.txHash) + .eq("transaction_type", "USER") + .eq("direction", "credit"); + + if (creditTxError) { + console.error("Credit transaction lookup error:", creditTxError); + return; + } + + for (const transaction of creditTransactions || []) { + // Skip if the status is already the one we want to set. + if (transaction.status === mappedStatus) continue; + + const currentStatus = transaction.status; + + // Don't downgrade status: if already confirmed it ('complete' or 'confirmed'), + // don't revert to 'pending' when Circle sends a pending notification + const statusPriority: Record = { + pending: 1, + confirmed: 2, + complete: 3, + failed: 0, // Failed can override any status + }; + + const currentPriority = statusPriority[currentStatus] || 0; + const newPriority = statusPriority[mappedStatus] || 0; + + // Only update if new status has higher priority (or it's a failure) + if (mappedStatus !== 'failed' && newPriority <= currentPriority) { + console.log(`Skipping status update for ${transaction.id}: '${currentStatus}' (priority ${currentPriority}) -> '${mappedStatus}' (priority ${newPriority})`); + continue; + } + + const isSuccessfulUpdate = (mappedStatus === 'confirmed' || mappedStatus === 'complete'); + const wasAlreadyProcessed = (currentStatus === 'confirmed' || currentStatus === 'complete'); + + // Only increment credits if the transaction is moving to a success state + // for the first time. This prevents double-crediting. + if (isSuccessfulUpdate && !wasAlreadyProcessed) { + console.log(`Transaction ${transaction.id} confirmed. Crediting user ${transaction.user_id} with ${transaction.credit_amount} credits.`); + + const { error: creditsError } = await supabaseAdminClient.rpc("increment_credits", { + user_id_to_update: transaction.user_id, + amount_to_add: transaction.credit_amount, + }); + + if (creditsError) { + // Log the error but continue, so we at least update the transaction status. + console.error(`CRITICAL: Failed to increment credits for user ${transaction.user_id} on transaction ${transaction.id}. Error:`, creditsError); + } else { + console.log(`Successfully credited user ${transaction.user_id}.`); + } + } + + // Update the transaction status regardless of the credit operation. + const { error: updateError } = await supabaseAdminClient + .from("transactions") + .update({ + status: mappedStatus, + updated_at: new Date().toISOString() + }) + .eq("id", transaction.id); + + if (updateError) { + console.error(`Failed updating transaction status for ${transaction.id}:`, updateError); + } else { + console.log(`Updated transaction ${transaction.id} status from '${currentStatus}' to '${mappedStatus}'`); + } + } +} + +async function updateAdminTransactionStatus( + transactionId: string, + notification: CircleNotification +) { + if (notification.state !== "CONFIRMED" && notification.state !== "COMPLETE") { + return; + } + + // Prepare update object - include tx_hash if available + const updateData: { status: string; updated_at: string; tx_hash?: string } = { + status: notification.state.toLowerCase(), + updated_at: new Date().toISOString() + }; + + if (notification.txHash) { + updateData.tx_hash = notification.txHash; + } + + const { data: transaction, error } = await supabaseAdminClient + .from("transactions") + .update(updateData) + .eq("circle_transaction_id", transactionId) + .neq("transaction_type", "USER") // Only update admin transactions + .select(`*, admin_wallets!source_wallet_id(circle_wallet_id, chain)`) + .single(); + + if (error) { + return; + } + + const adminWallet = Array.isArray(transaction.admin_wallets) + ? transaction.admin_wallets[0] + : transaction.admin_wallets; + + console.log(`Updated admin transaction ${transactionId} (type: ${transaction.transaction_type}) status to ${notification.state}`); + + if (notification.state === "COMPLETE" && notification.blockchain !== "ARC-TESTNET") { + return; + } + + // Only proceed with CCTP logic if the destination is an internal admin wallet + const { data: destinationWallet, error: destinationWalletError } = await supabaseAdminClient + .from("admin_wallets") + .select("id, chain, address, circle_wallet_id") + .eq("address", transaction.destination_address) + .single(); + + if (destinationWalletError || !destinationWallet) { + // This is expected for external wallet transfers - not an error + console.log(`External wallet transfer detected (destination: ${transaction.destination_address}). No CCTP bridging needed.`); + return; + } + + if (transaction.transaction_type === "CCTP_APPROVAL") { + console.log("[CCTP] Approval confirmed. Now, burning USDC from source wallet..."); + + const sourceChainKey = adminWallet.chain.replace(/-/g, '_'); + const sourceChainId = SupportedChainId[sourceChainKey as keyof typeof SupportedChainId]; + const destinationChainKey = destinationWallet.chain.replace(/-/g, '_'); + const destinationChainId = SupportedChainId[destinationChainKey as keyof typeof SupportedChainId]; + + if (sourceChainId === undefined || destinationChainId === undefined) { + throw new Error(`Unsupported chain. Source: ${adminWallet.chain}, Destination: ${destinationWallet.chain}`); + } + + const tokenMessengerAddress = CHAIN_IDS_TO_TOKEN_MESSENGER[sourceChainId]; + const usdcContractAddress = CHAIN_IDS_TO_USDC_ADDRESSES[sourceChainId]; + const destinationDomain = DESTINATION_DOMAINS[destinationChainId]; + + if (!tokenMessengerAddress || !usdcContractAddress || destinationDomain === undefined) { + throw new Error("Could not find required CCTP configuration for the given chains."); + } + + const finalDestinationAddress = destinationWallet.address; + const mintRecipientBytes32 = `0x${'0'.repeat(24)}${finalDestinationAddress.substring(2)}`; + const hookDataBytes32 = `0x${'0'.repeat(64)}`; + const amount = BigInt(convertToSmallestUnit(transaction.amount_usdc.toString())); + const maxFee = amount > 1n ? amount - 1n : 0n; + + const burnAbiParameters = [ + amount.toString(), + destinationDomain.toString(), + mintRecipientBytes32, + usdcContractAddress, + hookDataBytes32, + maxFee.toString(), + "1000" + ]; + + const burnResponse = await circleDeveloperSdk.createContractExecutionTransaction({ + walletId: adminWallet.circle_wallet_id, + abiFunctionSignature: "depositForBurn(uint256,uint32,bytes32,address,bytes32,uint256,uint32)", + abiParameters: burnAbiParameters, + contractAddress: tokenMessengerAddress, + fee: { type: "level", config: { feeLevel: "MEDIUM" } } + }); + + if (!burnResponse.data?.id) { + throw new Error("Failed to initiate burn step of CCTP transfer."); + } + + await supabaseAdminClient.from("transactions").insert({ + transaction_type: "CCTP_BURN", + circle_transaction_id: burnResponse.data.id, + source_wallet_id: transaction.source_wallet_id, + destination_address: transaction.destination_address, + amount_usdc: transaction.amount_usdc, + asset: "USDC", + chain: destinationWallet.chain, + wallet_id: transaction.destination_address, + idempotency_key: `admin:${burnResponse.data.id}`, + status: "pending" + }); + return; + } + + if (transaction.transaction_type === "CCTP_BURN") { + console.log("[CCTP] Burn confirmed. Fetching attestation via v2 API..."); + + const transactionResponse = await circleDeveloperSdk.getTransaction({ id: transaction.circle_transaction_id }); + if (!transactionResponse.data?.transaction?.txHash) { + throw new Error("Transaction hash is missing from the response."); + } + const transactionHash = transactionResponse.data.transaction.txHash as `0x${string}`; + + const sourceChainKey = transactionResponse.data.transaction.blockchain.replace(/_/g, '-'); + const sourceChainEnumKey = sourceChainKey.replace(/-/g, '_'); + const sourceChainId = SupportedChainId[sourceChainEnumKey as keyof typeof SupportedChainId]; + const sourceDomain = DESTINATION_DOMAINS[sourceChainId]; + + if (sourceDomain === undefined) { + throw new Error(`Unknown source chain for CCTP v2: ${sourceChainKey}`); + } + + const irisUrl = `https://iris-api-sandbox.circle.com/v2/messages/${sourceDomain}?transactionHash=${transactionHash}`; + let irisMessageObject: Record | null = null; + + while (!irisMessageObject) { + try { + const response = await fetch(irisUrl); + if (response.status === 404) { + console.log("[CCTP] Waiting for message to be indexed by Iris..."); + } else if (response.ok) { + const responseData = await response.json(); + if (responseData.messages && responseData.messages[0]?.status === 'complete') { + irisMessageObject = responseData.messages[0]; + break; + } else { + console.log("[CCTP] Attestation is not yet complete. Waiting..."); + } + } else { + console.error(`[CCTP] Iris API returned an error: ${response.status}`); + } + } catch (error) { + console.error("[CCTP] Error fetching from Iris API:", error); + } + await new Promise((r) => setTimeout(r, 5000)); + } + + if (!irisMessageObject) { + throw new Error("Failed to retrieve a complete attestation from the Iris API."); + } + + console.log("[CCTP] Attestation received successfully."); + const messageBytes = irisMessageObject.message; + const attestation = irisMessageObject.attestation; + + console.log("[CCTP] Relaying mint transaction via Circle Bridge Kit..."); + + const destinationChainKey = destinationWallet.chain.replace(/-/g, '_'); + const destinationChainId = SupportedChainId[destinationChainKey as keyof typeof SupportedChainId]; + const messageTransmitterAddress = CHAIN_IDS_TO_MESSAGE_TRANSMITTER[destinationChainId]; + + if (!messageTransmitterAddress) { + throw new Error(`Could not find MessageTransmitter address for chain: ${destinationWallet.chain}`); + } + + // Use Circle Wallets (Developer-Controlled Wallets) to execute receiveMessage on destination. + // This follows the Circle Wallets adapter setup for Bridge Kit: execute via Circle Wallets API. + const abiFunctionSignature = "receiveMessage(bytes,bytes)"; + const abiParameters = [messageBytes, attestation]; + + // We execute on the destination admin wallet (internal wallet check already passed). + const execResp = await circleDeveloperSdk.createContractExecutionTransaction({ + walletId: destinationWallet.circle_wallet_id ?? adminWallet.circle_wallet_id, + contractAddress: messageTransmitterAddress, + abiFunctionSignature, + abiParameters, + fee: { type: "level", config: { feeLevel: "MEDIUM" } } + }); + + if (!execResp.data?.id) { + throw new Error("Failed to relay mint via Circle Wallets."); + } + + const txHash = execResp.data.id; + + console.log(`[CCTP] Mint transaction submitted via Bridge Kit. txHash: ${txHash}`); + + // Dedupe: avoid creating multiple CCTP_MINT rows for the same burn or Circle tx. + const { data: existingMintByTxId } = await supabaseAdminClient + .from("transactions") + .select("id") + .eq("transaction_type", "CCTP_MINT") + .eq("circle_transaction_id", txHash) + .maybeSingle(); + + const { data: existingMintByBurnMeta } = await supabaseAdminClient + .from("transactions") + .select("id") + .eq("transaction_type", "CCTP_MINT") + .contains("metadata", { cctp_burn_tx_id: transaction.id }) + .maybeSingle(); + + if (existingMintByTxId || existingMintByBurnMeta) { + console.log(`[CCTP] Mint transaction already recorded. Skipping duplicate insert. txId: ${txHash}, burnId: ${transaction.id}`); + return; + } + + const { error: insertError } = await supabaseAdminClient.from("transactions").insert({ + transaction_type: "CCTP_MINT", + circle_transaction_id: txHash, + source_wallet_id: transaction.source_wallet_id, + destination_address: transaction.destination_address, + amount_usdc: transaction.amount_usdc, + asset: "USDC", + chain: destinationWallet.chain, + wallet_id: transaction.destination_address, + idempotency_key: `admin:${txHash}`, + status: "pending", + metadata: { cctp_burn_tx_id: transaction.id } + }); + + if (insertError) { + if ((insertError as any).code === "23505") { + // Unique constraint hit (e.g., idempotency_key). Treat as success and continue. + console.log("[CCTP] Mint insert deduped by unique constraint."); + } else { + console.error(`[CCTP] Failed to insert CCTP_MINT record. Error: ${insertError.message}`); + throw insertError; + } + } + return; + } +} + +async function verifyCircleSignature(bodyString: string, signature: string, keyId: string): Promise { + try { + const publicKey = await getCirclePublicKey(keyId); + const verifier = crypto.createVerify("SHA256"); + verifier.update(bodyString); + verifier.end(); + const signatureUint8Array = Uint8Array.from(Buffer.from(signature, "base64")); + return verifier.verify(publicKey, signatureUint8Array); + } catch (e) { + console.error("Signature verification failure:", e); + return false; + } +} + +async function getCirclePublicKey(keyId: string) { + if (!process.env.CIRCLE_API_KEY) { + throw new Error("Circle API key is not set"); + } + const response = await fetch(`https://api.circle.com/v2/notifications/publicKey/${keyId}`, { + method: "GET", + headers: { Accept: "application/json", Authorization: `Bearer ${process.env.CIRCLE_API_KEY}` }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch public key: ${response.statusText}`); + } + const data = await response.json(); + const rawPublicKey = data?.data?.publicKey; + if (typeof rawPublicKey !== "string") { + throw new Error("Invalid public key format"); + } + return ["-----BEGIN PUBLIC KEY-----", ...(rawPublicKey.match(/.{1,64}/g) ?? []), "-----END PUBLIC KEY-----"].join("\n"); +} + +export async function POST(req: NextRequest) { + try { + const signature = req.headers.get("x-circle-signature"); + const keyId = req.headers.get("x-circle-key-id"); + + if (!signature || !keyId) { + return NextResponse.json({ error: "Missing signature or keyId in headers" }, { status: 400 }); + } + + const rawBody = await req.text(); + let body: CircleWebhookPayload; + try { + body = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const isVerified = await verifyCircleSignature(rawBody, signature, keyId); + if (!isVerified) { + console.warn("Circle webhook: signature verification failed"); + return NextResponse.json({ error: "Invalid signature" }, { status: 403 }); + } + + console.log("Circle webhook notification:", body); + + if (!body.subscriptionId || !body.notificationId || !body.notificationType) { + return NextResponse.json({ error: "Malformed webhook payload - missing required fields" }, { status: 422 }); + } + + const notification = body.notification; + if (!notification) { + return NextResponse.json({ error: "Malformed notification payload" }, { status: 422 }); + } + + const circleEventId = body.notificationId; + const circleTransactionId = notification.id; + const mappedStatus = mapCircleStateToStatus(notification.state); + + await logWebhookEvent(rawBody, body, circleEventId, circleTransactionId, mappedStatus, isVerified); + + if (body.notificationType === "webhooks.test") { + console.log("Received test webhook notification - validation successful"); + return NextResponse.json({ received: true }, { status: 200 }); + } + + if (circleTransactionId) { + await updateTransactionStatus(notification); + await updateAdminTransactionStatus(circleTransactionId, notification); + } + + return NextResponse.json({ received: true }, { status: 200 }); + } catch (error) { + console.error("Failed to process Circle webhook:", error); + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: `Failed to process notification: ${message}` }, { status: 500 }); + } +} + +export async function HEAD() { + return NextResponse.json({}, { status: 200 }); +} diff --git a/app/api/deposit/route.test.ts b/app/api/deposit/route.test.ts deleted file mode 100644 index c123c39..0000000 --- a/app/api/deposit/route.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright 2026 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 { handleDeposit } from '@/lib/deposit'; - -describe('handleDeposit', () => { - it('returns success for valid deposit', async () => { - const result = await handleDeposit({ - userId: 'user-1', - chain: 'Ethereum', - amount: 100, - }); - if ('success' in result) { - expect(result.success).toBe(true); - expect(result.depositResult).toBeDefined(); - } else { - throw new Error('Expected success result'); - } - }); - - it('returns error for missing userId', async () => { - const result = await handleDeposit({ - chain: 'Ethereum', - amount: 100, - }); - if ('error' in result) { - expect(result.error).toBeDefined(); - } else { - throw new Error('Expected error result'); - } - }); - - it('returns error for missing chain', async () => { - const result = await handleDeposit({ - userId: 'user-1', - amount: 100, - }); - if ('error' in result) { - expect(result.error).toBeDefined(); - } else { - throw new Error('Expected error result'); - } - }); - - it('returns error for missing amount', async () => { - const result = await handleDeposit({ - userId: 'user-1', - chain: 'Ethereum', - }); - if ('error' in result) { - expect(result.error).toBeDefined(); - } else { - throw new Error('Expected error result'); - } - }); -}); diff --git a/app/api/destination-wallet/route.ts b/app/api/destination-wallet/route.ts new file mode 100644 index 0000000..31eb686 --- /dev/null +++ b/app/api/destination-wallet/route.ts @@ -0,0 +1,53 @@ +/** + * 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 { NextResponse } from "next/server"; +import { supabaseAdminClient } from "@/lib/supabase/admin-client"; + +export const dynamic = 'force-dynamic'; // Ensures the route is not cached + +export async function GET() { + try { + const { data, error } = await supabaseAdminClient + .from("admin_wallets") + .select("address") + .order("created_at", { ascending: true }) // Get the oldest row first + .limit(1) + .single(); // Expect only one row + + if (error) { + console.error("Supabase query error:", error); + // RLS errors can be cryptic, so provide a clearer message. + if (error.code === 'PGRST116') { + return NextResponse.json({ error: "No destination wallet found in the database." }, { status: 404 }); + } + throw error; + } + + if (!data || !data.address) { + return NextResponse.json({ error: "No destination wallet found." }, { status: 404 }); + } + + return NextResponse.json({ address: data.address }); + + } catch (error) { + const message = error instanceof Error ? error.message : "An unknown error occurred."; + console.error("Failed to fetch destination wallet:", message); + return NextResponse.json({ error: "Failed to fetch destination wallet." }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/gateway/balance/route.ts b/app/api/gateway/balance/route.ts deleted file mode 100644 index 792b24d..0000000 --- a/app/api/gateway/balance/route.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Copyright 2026 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 { fetchGatewayBalance, getUsdcBalance, CHAIN_BY_DOMAIN, type SupportedChain } from "@/lib/circle/gateway-sdk"; -import { createClient } from "@/lib/supabase/server"; -import type { Address } from "viem"; - -export async function POST(req: NextRequest) { - try { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { addresses } = await req.json(); - - if (!addresses || !Array.isArray(addresses) || addresses.length === 0) { - return NextResponse.json( - { error: "Missing or invalid addresses array" }, - { status: 400 } - ); - } - - const supportedChains: SupportedChain[] = [ - "arcTestnet", - "baseSepolia", - "avalancheFuji", - ]; - - // Fetch balances for all addresses - const balancePromises = addresses.map(async (address: string) => { - try { - // Fetch Gateway balance (available balance on Gateway contracts) - let gatewayBalances: Array<{ domain: number; balance: number; chain: string }> = []; - let gatewayTotal = 0; - - try { - const gatewayResponse = await fetchGatewayBalance(address as Address); - console.log(`Gateway API response for ${address}:`, JSON.stringify(gatewayResponse, null, 2)); - - gatewayBalances = gatewayResponse.balances.map((b) => { - // Gateway API returns balance as decimal string (e.g., "1.000000"), not atomic units - const balance = parseFloat(b.balance); - const chainName = CHAIN_BY_DOMAIN[b.domain] || "unknown"; - - console.log(`Gateway balance on domain ${b.domain} (${chainName}): ${balance} USDC`); - - return { - domain: b.domain, - balance, - chain: chainName, - address, - }; - }); - - gatewayTotal = gatewayBalances.reduce((sum, b) => sum + b.balance, 0); - console.log(`Total Gateway balance for ${address}: ${gatewayTotal} USDC`); - } catch (error: any) { - console.error(`Error fetching Gateway balance for ${address}:`, error.message); - console.log(`Will fetch on-chain balances only`); - } - - // Fetch on-chain USDC balances (wallet balances not yet deposited) - const chainBalances = await Promise.all( - supportedChains.map(async (chain) => { - try { - const balance = await getUsdcBalance(address as Address, chain); - return { - chain, - balance: Number(balance) / 1_000_000, // Convert to USDC - address, - }; - } catch (error) { - console.error(`Error fetching on-chain balance for ${chain}:`, error); - return { - chain, - balance: 0, - address, - }; - } - }) - ); - - // Calculate total from on-chain balances (wallet balance) - const walletTotal = chainBalances.reduce((sum, cb) => sum + cb.balance, 0); - - return { - address, - gatewayBalances, - gatewayTotal, - chainBalances, - walletTotal, - totalBalance: gatewayTotal + walletTotal, - }; - } catch (error: any) { - console.error(`Error fetching balance for ${address}:`, error); - return { - address, - error: error.message, - totalBalance: 0, - }; - } - }); - - const balances = await Promise.all(balancePromises); - - // Calculate total unified balance from all addresses - const totalUnified = balances.reduce((sum, b) => { - return sum + (b.totalBalance || 0); - }, 0); - - return NextResponse.json({ - success: true, - totalUnified, - balances, - }); - } catch (error: any) { - console.error("Error fetching balances:", error); - return NextResponse.json( - { error: error.message || "Internal server error" }, - { status: 500 } - ); - } -} diff --git a/app/api/gateway/deposit/route.ts b/app/api/gateway/deposit/route.ts deleted file mode 100644 index 1d3f45c..0000000 --- a/app/api/gateway/deposit/route.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Copyright 2026 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 { - initiateDepositFromCustodialWallet, - type SupportedChain, -} from "@/lib/circle/gateway-sdk"; -import { createClient } from "@/lib/supabase/server"; - -export async function POST(req: NextRequest) { - try { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { chain, amount } = await req.json(); - - if (!chain || !amount) { - return NextResponse.json( - { error: "Missing required fields: chain, amount" }, - { status: 400 } - ); - } - - // Validate chain - const validChains: SupportedChain[] = ["arcTestnet", "baseSepolia", "avalancheFuji"]; - if (!validChains.includes(chain)) { - return NextResponse.json( - { error: `Invalid chain. Must be one of: ${validChains.join(", ")}` }, - { status: 400 } - ); - } - - // Convert amount to bigint (amount should be in USDC, multiply by 1_000_000) - const parsedAmount = parseFloat(amount); - - // Validate if amount is positive - if (parsedAmount <= 0) { - return NextResponse.json( - { error: "Amount must be greater than 0" }, - { status: 400 } - ); - } - - // Validate amount is not too large (max 1 billion USDC for safety) - if (parsedAmount > 1_000_000_000) { - return NextResponse.json( - { error: "Amount exceeds maximum allowed value" }, - { status: 400 } - ) - } - - const amountInAtomicUnits = BigInt(Math.floor(parsedAmount * 1_000_000)); - - // Custodial flow (Circle Wallet) - const { data: wallet, error: walletError } = await supabase - .from("wallets") - .select("circle_wallet_id") - .eq("user_id", user.id) - .single(); - - if (walletError || !wallet) { - return NextResponse.json( - { error: "No Circle wallet found for this user." }, - { status: 404 } - ); - } - - const txHash = await initiateDepositFromCustodialWallet( - wallet.circle_wallet_id, - chain as SupportedChain, - amountInAtomicUnits - ); - - // Store transaction in database - await supabase.from("transaction_history").insert([ - { - user_id: user.id, - chain, - tx_type: "deposit", - amount: parseFloat(amount), - tx_hash: txHash, - // This should probably be dynamic if you support multiple gateways - gateway_wallet_address: "0x0077777d7EBA4688BDeF3E311b846F25870A19B9", - status: "success", - created_at: new Date().toISOString(), - }, - ]); - - return NextResponse.json({ - success: true, - txHash, - chain, - amount: parseFloat(amount), - }); - } catch (error: any) { - console.error("Error in deposit:", error); - - // Log failed transaction to database - try { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (user) { - const body = await req.json(); - await supabase.from("transaction_history").insert([ - { - user_id: user.id, - chain: body.chain, - tx_type: "deposit", - amount: parseFloat(body.amount || 0), - status: "failed", - reason: error.message || "Unknown error", - created_at: new Date().toISOString(), - }, - ]); - } - } catch (dbError) { - console.error("Error logging failed transaction:", dbError); - } - - // Handle specific error types for better user feedback - - let errorMessage = "Internal server error"; - let statusCode = 500; - - if (error.message) { - const msg = error.message.toLowerCase(); - if (msg.includes("gas") || msg.includes("intrinsic") || msg.includes("fee")) { - errorMessage = "Insufficient gas or gas estimation failed. Please ensure you have enough native tokens for gas fees."; - statusCode = 400; - } - // Insufficient balance - check for multiple variations - else if ( - msg.includes("insufficient funds") || - msg.includes("insufficient balance") || - msg.includes("transfer amount exceeds balance") || - msg.includes("exceeds balance") - ) { - errorMessage = "Insufficient USDC balance for this deposit. Please check your wallet balance and try again."; - statusCode = 400; - } else if (msg.includes("allowance") || msg.includes("approve")) { - errorMessage = "Token approval failed. Please try again."; - statusCode = 400; - } else if (msg.includes("network") || msg.includes("rpc") || msg.includes("timeout")) { - errorMessage = "Network error. Please check your connection and try again."; - statusCode = 503; - } else if (msg.includes("user rejected") || msg.includes("user denied")) { - errorMessage = "Transaction was rejected."; - statusCode = 400; - } else if (error.message.length < 200) { - errorMessage = error.message; - } - } - - return NextResponse.json( - { error: errorMessage }, - { status: statusCode } - ); - } -} \ No newline at end of file diff --git a/app/api/gateway/info/route.ts b/app/api/gateway/info/route.ts deleted file mode 100644 index 9f2699f..0000000 --- a/app/api/gateway/info/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Copyright 2026 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 { fetchGatewayInfo } from "@/lib/circle/gateway-sdk"; -import { createClient } from "@/lib/supabase/server"; - -export async function GET(req: NextRequest) { - try { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - // Fetch Gateway info - const info = await fetchGatewayInfo(); - - return NextResponse.json({ - success: true, - ...info, - }); - } catch (error: any) { - console.error("Error fetching Gateway info:", error); - return NextResponse.json( - { error: error.message || "Internal server error" }, - { status: 500 } - ); - } -} diff --git a/app/api/gateway/transfer/route.ts b/app/api/gateway/transfer/route.ts deleted file mode 100644 index d2a9389..0000000 --- a/app/api/gateway/transfer/route.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Copyright 2026 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 { - transferUnifiedBalanceCircle, - type SupportedChain, -} from "@/lib/circle/gateway-sdk"; -import { createClient } from "@/lib/supabase/server"; -import type { Address } from "viem"; - -export async function POST(req: NextRequest) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { sourceChain, destinationChain, amount, recipientAddress } = - await req.json(); - - try { - if (!sourceChain || !destinationChain || !amount) { - return NextResponse.json( - { - error: - "Missing required fields: sourceChain, destinationChain, amount", - }, - { status: 400 } - ); - } - - // Validate chains - const validChains: SupportedChain[] = [ - "baseSepolia", - "avalancheFuji", - "arcTestnet" - ]; - if ( - !validChains.includes(sourceChain) || - !validChains.includes(destinationChain) - ) { - return NextResponse.json( - { error: `Invalid chain. Must be one of: ${validChains.join(", ")}` }, - { status: 400 } - ); - } - - if (sourceChain === destinationChain) { - return NextResponse.json( - { error: "Source and destination chains must be different" }, - { status: 400 } - ); - } - - const amountInAtomicUnits = BigInt(Math.floor(parseFloat(amount) * 1_000_000)); - - // Custodial flow (Circle Wallet) - const { data: wallet, error: walletError } = await supabase - .from("wallets") - .select("circle_wallet_id") - .eq("user_id", user.id) - .single(); - - if (walletError || !wallet?.circle_wallet_id) { - return NextResponse.json( - { error: "No Circle wallet found for this user." }, - { status: 404 } - ); - } - - const transferResult = await transferUnifiedBalanceCircle( - wallet.circle_wallet_id, - amountInAtomicUnits, - sourceChain as SupportedChain, - destinationChain as SupportedChain, - recipientAddress as Address | undefined - ); - - const { burnTxHash, attestation, mintTxHash } = transferResult; - - // Store transaction in database - await supabase.from("transaction_history").insert([ - { - user_id: user.id, - chain: sourceChain, - tx_type: "transfer", - amount: parseFloat(amount), - tx_hash: mintTxHash, - gateway_wallet_address: "0x0077777d7EBA4688BDeF3E311b846F25870A19B9", - destination_chain: destinationChain, - status: "success", - created_at: new Date().toISOString(), - }, - ]); - - return NextResponse.json({ - success: true, - burnTxHash, - attestation, - mintTxHash, - sourceChain, - destinationChain, - amount: parseFloat(amount), - }); - } catch (error: any) { - console.error("Error in transfer:", error); - - // Log failed transaction to database - try { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (user) { - await supabase.from("transaction_history").insert([ - { - user_id: user.id, - chain: sourceChain, - tx_type: "transfer", - amount: parseFloat(amount || 0), - destination_chain: destinationChain, - status: "failed", - reason: error.message || "Unknown error", - created_at: new Date().toISOString(), - }, - ]); - } - } catch (dbError) { - console.error("Error logging failed transaction:", dbError); - } - - return NextResponse.json( - { error: error.message || "Internal server error" }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/openzepellin/webhook/route.ts b/app/api/openzepellin/webhook/route.ts new file mode 100644 index 0000000..d69dda2 --- /dev/null +++ b/app/api/openzepellin/webhook/route.ts @@ -0,0 +1,83 @@ +/** + * 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"; + +// This interface is a simplified version of the relayer's payload. +interface RelayerNotificationPayload { + payload_type: "transaction"; + id: string; // This is the relayer's transaction ID + status: "sent" | "confirmed" | "failed"; +} + +export async function POST(req: NextRequest) { + try { + // In production, you would verify the signature from the relayer + // using the WEBHOOK_SIGNING_KEY you configured. + const body = await req.json(); + const notification = body.payload as RelayerNotificationPayload; + + console.log("Received notification from OpenZeppelin Relayer:", notification); + + // We only care about the final states: 'confirmed' or 'failed'. + if (notification.status !== "confirmed" && notification.status !== "failed") { + return NextResponse.json({ received: true }); + } + + // Find the CCTP_MINT transaction that corresponds to this relayer transaction ID. + const { data: transaction, error } = await supabaseAdminClient + .from("transactions") + .select("id, status") + .eq("circle_transaction_id", notification.id) + .eq("transaction_type", "CCTP_MINT") + .single(); + + if (error || !transaction) { + console.warn(`No matching CCTP_MINT transaction found for relayer txId: ${notification.id}`); + return NextResponse.json({ received: true }); + } + + // Determine the new status (lowercase to match transaction_status enum) + const newStatus = notification.status === "confirmed" ? "complete" : "failed"; + + // Idempotency check: If the status is already correct, do nothing. + if (transaction.status === newStatus) { + return NextResponse.json({ received: true }); + } + + // Update the transaction status to complete or failed. + const { error: updateError } = await supabaseAdminClient + .from("transactions") + .update({ status: newStatus, updated_at: new Date().toISOString() }) + .eq("id", transaction.id); + + if (updateError) { + throw new Error(`Failed to update admin transaction ${transaction.id}: ${updateError.message}`); + } + + console.log(`[CCTP] Finalized transaction ${transaction.id} with status ${newStatus}`); + + return NextResponse.json({ received: true }); + + } catch (error) { + console.error("Failed to process OpenZeppelin webhook:", error); + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/transactions/[id]/route.ts b/app/api/transactions/[id]/route.ts new file mode 100644 index 0000000..785ecfd --- /dev/null +++ b/app/api/transactions/[id]/route.ts @@ -0,0 +1,297 @@ +/** + * 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 { createClient as createServerSupabase } from "@/lib/supabase/server"; +import { supabaseAdminClient } from "@/lib/supabase/admin-client"; + +/** + * GET /api/transactions/[id] + * Fetches a single transaction by ID for the authenticated user + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + + if (!id) { + return NextResponse.json( + { error: "Transaction ID is required" }, + { status: 400 } + ); + } + + // Get authenticated user + const supabase = await createServerSupabase(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Fetch transaction (RLS will ensure user can only see their own) + const { data: transaction, error: txError } = await supabase + .from("transactions") + .select("*") + .eq("id", id) + .single(); + + if (txError) { + if (txError.code === "PGRST116") { + return NextResponse.json( + { error: "Transaction not found" }, + { status: 404 } + ); + } + return NextResponse.json( + { error: "Failed to fetch transaction", details: txError.message }, + { status: 500 } + ); + } + + if (!transaction) { + return NextResponse.json( + { error: "Transaction not found" }, + { status: 404 } + ); + } + + // Fetch related status events + const { data: statusEvents, error: eventsError } = await supabase + .from("transaction_events") + .select("*") + .eq("transaction_id", id) + .order("created_at", { ascending: true }); + + if (eventsError) { + console.error("Failed to fetch transaction events:", eventsError); + // Continue without events rather than failing the request + } + + // Transform the response to match our expected format + const response = { + id: transaction.id, + credits: transaction.credit_amount, + usdcAmount: transaction.amount_usdc, + txHash: transaction.tx_hash, + chainId: parseInt(transaction.chain), + status: transaction.status, + createdAt: transaction.created_at, + updatedAt: transaction.updated_at, + fee: transaction.fee_usdc, + walletId: transaction.wallet_id, + userId: transaction.user_id, + direction: transaction.direction, + asset: transaction.asset, + exchangeRate: transaction.exchange_rate, + metadata: transaction.metadata, + idempotencyKey: transaction.idempotency_key, + statusEvents: statusEvents || [], + }; + + return NextResponse.json(response, { status: 200 }); + } catch (error) { + console.error("Transaction API error:", error); + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json( + { error: "Server error", details: message }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/transactions/[id] + * Updates transaction status when MetaMask confirms the transaction on-chain. + * + * This provides faster feedback than waiting for Circle webhooks. + * Only allows updating to 'completed' status to prevent abuse. + * + * Expected JSON body: + * { + * "status": "completed", + * "txHash": string, // Must match the transaction's tx_hash for security + * "blockNumber": number, // Optional: block number where tx was mined + * "blockHash": string // Optional: block hash for verification + * } + */ +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const body = await req.json().catch(() => ({})); + const { status, txHash, blockNumber, blockHash } = body || {}; + + // Validate transaction ID + if (!id || typeof id !== "string") { + return NextResponse.json( + { error: "Invalid transaction ID" }, + { status: 400 } + ); + } + + // Only allow updating to 'complete' from client + if (status !== "complete") { + return NextResponse.json( + { error: "Only 'complete' status updates are allowed from client" }, + { status: 400 } + ); + } + + // Require txHash for security - ensures caller actually has transaction details + if (typeof txHash !== "string" || !txHash.startsWith("0x")) { + return NextResponse.json( + { error: "Valid txHash is required" }, + { status: 400 } + ); + } + + // Get authenticated user + const supabase = await createServerSupabase(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Fetch the transaction to verify ownership and current status + const { data: transaction, error: fetchError } = await supabaseAdminClient + .from("transactions") + .select("*") + .eq("id", id) + .single(); + + if (fetchError || !transaction) { + return NextResponse.json( + { error: "Transaction not found" }, + { status: 404 } + ); + } + + // Verify ownership + if (transaction.user_id !== user.id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Verify txHash matches (security check) + if (transaction.tx_hash !== txHash) { + return NextResponse.json( + { error: "Transaction hash mismatch" }, + { status: 400 } + ); + } + + // Only update if currently in 'pending' status + // Don't override Circle's authoritative updates + if (transaction.status !== "pending") { + return NextResponse.json( + { + ok: true, + message: `Transaction already in '${transaction.status}' status, no update needed`, + transaction: { + id: transaction.id, + status: transaction.status, + updatedAt: transaction.updated_at, + }, + }, + { status: 200 } + ); + } + + // Build metadata with blockchain confirmation details + const metadata = { + ...(transaction.metadata || {}), + metamask_confirmation: { + confirmed_at: new Date().toISOString(), + block_number: blockNumber, + block_hash: blockHash, + }, + }; + + // Increment user credits if this is a credit transaction + if (transaction.direction === "credit" && transaction.credit_amount && transaction.user_id) { + console.log(`Transaction ${transaction.id} completed. Crediting user ${transaction.user_id} with ${transaction.credit_amount} credits.`); + + const { error: creditsError } = await supabaseAdminClient.rpc("increment_credits", { + user_id_to_update: transaction.user_id, + amount_to_add: transaction.credit_amount, + }); + + if (creditsError) { + console.error(`CRITICAL: Failed to increment credits for user ${transaction.user_id} on transaction ${transaction.id}. Error:`, creditsError); + // Continue with status update even if credits fail - we can fix this manually + } else { + console.log(`Successfully credited user ${transaction.user_id}.`); + } + } + + // Update transaction to 'complete' status + const { data: updatedTransaction, error: updateError } = + await supabaseAdminClient + .from("transactions") + .update({ + status: "complete", + metadata, + updated_at: new Date().toISOString(), + }) + .eq("id", id) + .select() + .single(); + + if (updateError) { + console.error("[transactions/PATCH] Update error:", updateError); + return NextResponse.json( + { error: "Update failed", details: updateError.message }, + { status: 500 } + ); + } + + return NextResponse.json( + { + ok: true, + message: "Transaction status updated to complete", + transaction: { + id: updatedTransaction.id, + status: updatedTransaction.status, + credits: Number(updatedTransaction.credit_amount), + usdcAmount: Number(updatedTransaction.amount_usdc), + txHash: updatedTransaction.tx_hash, + chainId: Number(updatedTransaction.chain), + updatedAt: updatedTransaction.updated_at, + metadata: updatedTransaction.metadata, + }, + }, + { status: 200 } + ); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Unknown error"; + console.error("[transactions/PATCH] Server error:", e); + return NextResponse.json( + { error: "Server error", details: message }, + { status: 500 } + ); + } +} diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts index 2c24ecb..ff6b0b6 100644 --- a/app/api/transactions/route.ts +++ b/app/api/transactions/route.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -16,40 +16,293 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { NextRequest, NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/server"; +import { NextRequest } from "next/server"; +import { supabaseAdminClient } from "@/lib/supabase/admin-client"; +import { createClient as createServerSupabase } from "@/lib/supabase/server"; -export async function GET(req: NextRequest) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); +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 } + ); + } - if (!user) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + 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 { data: transactions, error } = await supabase - .from("transaction_history") + 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("user_id", user.id) + .eq("transaction_type", "USER") .order("created_at", { ascending: false }); - if (error) { - console.error("Error fetching transaction history:", error); - return NextResponse.json( - { message: "Error fetching transaction history" }, + 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 } ); } - return NextResponse.json(transactions); - } catch (error) { - console.error("Error fetching transaction history:", error); - return NextResponse.json( - { message: "Error fetching transaction history" }, - { 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, + } ); } } diff --git a/app/api/wallet-set/route.ts b/app/api/wallet-set/route.ts index 773ecaa..d9cbf32 100644 --- a/app/api/wallet-set/route.ts +++ b/app/api/wallet-set/route.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -17,9 +17,9 @@ */ import { NextRequest, NextResponse } from "next/server"; -import { circleDeveloperSdk } from "@/lib/circle/sdk"; +import { circleDeveloperSdk } from "@/lib/circle/developer-controlled-wallets-client"; -export async function PUT(req: NextRequest) { +export async function POST(req: NextRequest) { try { const { entityName } = await req.json(); @@ -42,8 +42,11 @@ export async function PUT(req: NextRequest) { } return NextResponse.json({ ...response.data.walletSet }, { status: 201 }); - } catch (error: any) { - console.error(`Wallet set creation failed: ${error.message}`); + } catch (error: unknown) { + if (error instanceof Error) { + console.error(`Wallet set creation failed: ${error.message}`); + } + return NextResponse.json( { error: "Failed to create wallet set" }, { status: 500 } diff --git a/app/api/wallet/route.ts b/app/api/wallet/route.ts index ff5928c..67eaf56 100644 --- a/app/api/wallet/route.ts +++ b/app/api/wallet/route.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -16,12 +16,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { NextRequest, NextResponse } from "next/server"; -import { circleDeveloperSdk } from "@/lib/circle/sdk"; +import { NextResponse } from "next/server"; +import { circleDeveloperSdk } from "@/lib/circle/developer-controlled-wallets-client"; +import { Blockchain } from "@circle-fin/developer-controlled-wallets"; -export async function POST(req: NextRequest) { +export async function POST(request: Request) { try { - const { walletSetId } = await req.json(); + // Destructure the optional `blockchain` from the request body. + const { walletSetId, blockchain } = await request.json(); if (!walletSetId) { return NextResponse.json( @@ -30,28 +32,34 @@ export async function POST(req: NextRequest) { ); } + // Use the provided blockchain, or fall back to the environment variable. + const targetBlockchain = + blockchain || (process.env.CIRCLE_BLOCKCHAIN as Blockchain); + + if (!targetBlockchain) { + throw new Error( + "Blockchain must be provided in the request or as a CIRCLE_BLOCKCHAIN environment variable." + ); + } + const response = await circleDeveloperSdk.createWallets({ - accountType: "EOA", - blockchains: ["ARC-TESTNET", "BASE-SEPOLIA", "AVAX-FUJI"], - count: 1, walletSetId, + blockchains: [targetBlockchain], + count: 1, + accountType: "SCA", }); - if (!response.data?.wallets?.length) { - return NextResponse.json( - { error: "No wallets were created" }, - { status: 500 } - ); - } + const newWallet = response.data?.wallets?.[0]; - const [createdWallet] = response.data.wallets; + if (!newWallet) { + throw new Error("Circle API did not return a wallet object."); + } - return NextResponse.json(createdWallet, { status: 201 }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - return NextResponse.json( - { error: `Failed to create wallet: ${message}` }, - { status: 500 } - ); + return NextResponse.json(newWallet); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "An unexpected error occurred."; + console.error("Error in /api/wallet:", message); + return NextResponse.json({ error: message }, { status: 500 }); } -} +} \ No newline at end of file diff --git a/app/auth/confirm/route.ts b/app/auth/confirm/route.ts new file mode 100644 index 0000000..516f0cb --- /dev/null +++ b/app/auth/confirm/route.ts @@ -0,0 +1,48 @@ +/** + * 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 { createClient } from "@/lib/supabase/server"; +import { type EmailOtpType } from "@supabase/supabase-js"; +import { redirect } from "next/navigation"; +import { type NextRequest } from "next/server"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const token_hash = searchParams.get("token_hash"); + const type = searchParams.get("type") as EmailOtpType | null; + const next = searchParams.get("next") ?? "/"; + + if (token_hash && type) { + const supabase = await createClient(); + + const { error } = await supabase.auth.verifyOtp({ + type, + token_hash, + }); + if (!error) { + // redirect user to specified redirect URL or root of app + redirect(next); + } else { + // redirect the user to an error page with some instructions + redirect(`/auth/error?error=${error?.message}`); + } + } + + // redirect the user to an error page with some instructions + redirect(`/auth/error?error=No token hash or type`); +} diff --git a/app/auth/error/page.tsx b/app/auth/error/page.tsx index 49af973..7a42b3e 100644 --- a/app/auth/error/page.tsx +++ b/app/auth/error/page.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. diff --git a/components/ui/skeleton.tsx b/app/auth/forgot-password/page.tsx similarity index 63% rename from components/ui/skeleton.tsx rename to app/auth/forgot-password/page.tsx index 37a8af0..d75e861 100644 --- a/components/ui/skeleton.tsx +++ b/app/auth/forgot-password/page.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -16,16 +16,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { cn } from "@/lib/utils" +import { ForgotPasswordForm } from "@/components/forgot-password-form"; -function Skeleton({ className, ...props }: React.ComponentProps<"div">) { +export default function Page() { return ( -
- ) +
+
+ +
+
+ ); } - -export { Skeleton } diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx index 0015d30..e97d7e2 100644 --- a/app/auth/login/page.tsx +++ b/app/auth/login/page.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx index ef3256f..8190ba6 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. diff --git a/app/auth/update-password/page.tsx b/app/auth/update-password/page.tsx index be23d6a..8309a63 100644 --- a/app/auth/update-password/page.tsx +++ b/app/auth/update-password/page.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. diff --git a/app/dashboard/[txHash]/page.tsx b/app/dashboard/[txHash]/page.tsx new file mode 100644 index 0000000..4c818d6 --- /dev/null +++ b/app/dashboard/[txHash]/page.tsx @@ -0,0 +1,225 @@ +/** + * 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 { notFound, redirect } from "next/navigation"; +import Link from "next/link"; +import { createClient } from "@/lib/supabase/server"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ArrowLeft, ExternalLink } from "lucide-react"; +import { CopyTxHashButton } from "@/components/CopyTxHashButton"; +import { format } from "date-fns"; +import { getNetworkName, getExplorerUrl } from "@/lib/utils/chain-utils"; + +// This is a helper component for displaying rows in the receipt +const DetailRow = ({ label, value }: { label: string; value: React.ReactNode }) => ( +
+ {label} + {value} +
+); + +// This is the corrected async server component signature +export default async function TransactionDetailsPage( + props: { + params: Promise<{ txHash: string }>; + } +) { + const params = await props.params; + + const { + txHash + } = params; + + // Authenticate the user + const supabase = await createClient(); + const { data, error } = await supabase.auth.getUser(); + if (error || !data?.user) { + redirect("/auth/login"); + } + + // Fetch the real transaction by tx_hash + const { data: transaction, error: txError } = await supabase + .from("transactions") + .select("*") + .eq("tx_hash", txHash) + .single(); + + if (txError || !transaction) { + notFound(); + } + + // Fetch related status events + const { data: statusEvents } = await supabase + .from("transaction_events") + .select("*") + .eq("transaction_id", transaction.id) + .order("created_at", { ascending: true }); + + const networkName = getNetworkName(transaction.chain); + const explorerUrl = getExplorerUrl(transaction.chain, transaction.tx_hash); + + return ( +
+
+
+ {/* This button now correctly points back to the main dashboard */} + +

+ Transaction Details +

+

+ Full receipt and event log for your transaction. +

+
+ +
+ {/* Left side: Receipt */} +
+ + + Purchase Receipt + + Transaction ID: {transaction.id} + + + + + + {transaction.status.charAt(0).toUpperCase() + transaction.status.slice(1)} + + } + /> + + + + + + + + + + {transaction.tx_hash.slice(0, 10)}...{transaction.tx_hash.slice(-8)} + + + + +
+ } + /> + + +
+ + {/* Right side: Events */} +
+ + + Transaction Events + A log of status changes. + + + + + + Status + Timestamp + + + + {statusEvents && statusEvents.length > 0 ? ( + statusEvents.map((event) => ( + + + + {event.new_status.charAt(0).toUpperCase() + event.new_status.slice(1)} + + + {format(new Date(event.created_at), "PP")} + + )) + ) : ( + + + No status events yet + + + )} + +
+
+
+
+
+
+
+ ); +} diff --git a/app/dashboard/history/page.tsx b/app/dashboard/history/page.tsx deleted file mode 100644 index e6cdb7a..0000000 --- a/app/dashboard/history/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright 2026 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 { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; -import { TransactionHistory } from "@/components/transaction-history"; - -export default async function HistoryPage() { - const supabase = await createClient(); - - const { data, error } = await supabase.auth.getClaims(); - if (error || !data?.claims) { - redirect("/auth/login"); - } - - return ( -
-
-

Transaction History

-

- View and track all your deposit and transfer transactions -

-
- -
- ); -} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 5d4035d..025524b 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -16,12 +16,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import Link from "next/link"; import { EnvVarWarning } from "@/components/env-var-warning"; import { AuthButton } from "@/components/auth-button"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { hasEnvVars } from "@/lib/utils"; -import { Toaster } from "@/components/ui/sonner"; +import Link from "next/link"; +import { WalletProvider } from "@/components/wallet/wallet-provider"; export default function ProtectedLayout({ children, @@ -29,33 +29,26 @@ export default function ProtectedLayout({ children: React.ReactNode; }) { return ( -
-
- - -
- {children}
- -
- -
-
- - -
+ + ); -} +} \ No newline at end of file diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 10854b5..0b45d0d 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -17,25 +17,28 @@ */ import { redirect } from "next/navigation"; - import { createClient } from "@/lib/supabase/server"; -import { WalletDashboard } from "@/components/wallet-dashboard"; +import { AdminDashboard } from "@/components/admin-dashboard"; +import { UserDashboard } from "@/components/user-dashboard"; -export default async function ProtectedPage() { +export default async function DashboardPage() { const supabase = await createClient(); - const { data, error } = await supabase.auth.getClaims(); - if (error || !data?.claims) { + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + // 1. Ensure a user is logged in + if (error || !user) { redirect("/auth/login"); } - return ( -
-
-
- -
-
-
- ); -} + // 2. Perform the security check on the server + // We compare the user's email with the secure environment variable. + const isAdmin = user.email === process.env.ADMIN_EMAIL; + + // 3. Render the appropriate dashboard component + // A regular user's browser will never receive the component. + return isAdmin ? : ; +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index 795685a..3c04be1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -75,7 +75,7 @@ --destructive: oklch(0.396 0.141 25.723); --destructive-foreground: oklch(0.637 0.237 25.331); --border: oklch(0.269 0 0); - --input: oklch(1 0 0 / 15%); + --input: oklch(0.269 0 0); --ring: oklch(0.439 0 0); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); diff --git a/app/layout.tsx b/app/layout.tsx index eeaedc2..00d73b6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -18,7 +18,9 @@ import type { Metadata } from "next"; import { ThemeProvider } from "next-themes"; -import { WagmiProvider } from "@/components/wagmi-provider"; +import { WalletProvider } from "@/components/wallet/wallet-provider"; +import { UnsupportedNetworkNotice } from "@/components/wallet/unsupported-network-notice"; +import { Toaster } from "@/components/ui/sonner"; import "./globals.css"; const defaultUrl = process.env.VERCEL_URL @@ -27,8 +29,8 @@ const defaultUrl = process.env.VERCEL_URL export const metadata: Metadata = { metadataBase: new URL(defaultUrl), - title: "Multichain Gateway Wallet", - description: "Demo for wallet with unified cross-chain USDC balances and transfers", + title: "Next.js and Supabase Starter Kit", + description: "The fastest way to build apps with Next.js and Supabase", }; export default function RootLayout({ @@ -45,9 +47,13 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - + +
+ +
{children} -
+ + diff --git a/app/page.tsx b/app/page.tsx index d45abec..635dc19 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -18,10 +18,10 @@ import { EnvVarWarning } from "@/components/env-var-warning"; import { AuthButton } from "@/components/auth-button"; +import { Hero } from "@/components/hero"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { hasEnvVars } from "@/lib/utils"; import Link from "next/link"; -import { Hero } from "@/components/hero"; export default function Home() { return ( @@ -30,20 +30,16 @@ export default function Home() { -
- -
- -
); -} +} \ No newline at end of file diff --git a/components.json b/components.json index 335484f..4ee62ee 100644 --- a/components.json +++ b/components.json @@ -18,4 +18,4 @@ "hooks": "@/hooks" }, "iconLibrary": "lucide" -} \ No newline at end of file +} diff --git a/app/api/deposit/route.ts b/components/CopyTxHashButton.tsx similarity index 56% rename from app/api/deposit/route.ts rename to components/CopyTxHashButton.tsx index d2d0b76..f27b964 100644 --- a/app/api/deposit/route.ts +++ b/components/CopyTxHashButton.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -16,14 +16,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { NextRequest, NextResponse } from 'next/server'; -import { handleDeposit } from '@/lib/deposit'; +// components/CopyTxHashButton.tsx +"use client"; +import { Button } from "@/components/ui/button"; +import { Copy } from "lucide-react"; -export async function POST(req: NextRequest) { - const params = await req.json(); - const result = await handleDeposit(params); - if ('error' in result) { - return NextResponse.json({ error: result.error }, { status: 400 }); - } - return NextResponse.json(result); +export function CopyTxHashButton({ txHash }: { txHash: string }) { + return ( + + ); } diff --git a/components/admin-dashboard.tsx b/components/admin-dashboard.tsx new file mode 100644 index 0000000..6cdc9ee --- /dev/null +++ b/components/admin-dashboard.tsx @@ -0,0 +1,78 @@ +/** + * 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 type { Database } from "@/types/supabase"; +import { createClient } from "@supabase/supabase-js"; +import { AdminWalletsTable } from "@/components/admin-wallets-table/table"; +import { columns as walletColumns } from "@/components/admin-wallets-table/columns"; +import { AdminTransactionsTable } from "@/components/admin-transactions-table/table"; +import { columns as transactionColumns } from "@/components/admin-transactions-table/columns"; + +export async function AdminDashboard() { + const supabaseAdmin = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); + + // First fetch admin wallets to get their addresses + const { data: wallets, error: walletsError } = await supabaseAdmin + .from("admin_wallets") + .select("*") + .order("created_at", { ascending: false }); + + if (walletsError) { + console.error("Error fetching admin wallets:", walletsError.message); + } + + const adminWalletAddresses = wallets?.map(w => w.address) ?? []; + + // Fetch all transactions and filter on the server side + const { data: allTransactions, error: transactionsError } = await supabaseAdmin + .from("transactions") + .select("*, source_wallet:admin_wallets(label)") + .order("created_at", { ascending: false }); + + if (transactionsError) { + console.error("Error fetching admin transactions:", transactionsError.message); + } + + // Filter: include non-USER transactions OR USER transactions sent to admin wallets + const transactions = allTransactions?.filter(tx => { + const isAdminTransaction = tx.transaction_type !== "USER"; + const isUserToAdminWallet = + tx.transaction_type === "USER" && + tx.destination_address && + adminWalletAddresses.includes(tx.destination_address); + return isAdminTransaction || isUserToAdminWallet; + }) ?? []; + + return ( +
+
+

Admin Panel

+

+ Platform operator dashboard and administrative tools. +

+
+ + + {/* Pass the initial data to the table component */} + +
+ ); +} \ No newline at end of file diff --git a/components/admin-transactions-table/columns.tsx b/components/admin-transactions-table/columns.tsx new file mode 100644 index 0000000..165a743 --- /dev/null +++ b/components/admin-transactions-table/columns.tsx @@ -0,0 +1,124 @@ +/** + * 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 + */ + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Badge } from "@/components/ui/badge"; +import { CopyableCell } from "@/components/admin-wallets-table/columns"; +import { AdminTransaction } from "@/types/admin-transaction"; +import { getExplorerUrl } from "@/lib/utils/chain-utils"; +import { ClientDate } from "@/components/ui/client-date"; + +export const columns: ColumnDef[] = [ + { + accessorKey: "circle_transaction_id", + header: "Transaction ID", + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "source_wallet", + header: "Source", + cell: ({ row }) => { + // For USER transactions, show the user's wallet_id (truncated) + if (row.original.transaction_type === "USER" && row.original.wallet_id) { + const wallet = row.original.wallet_id; + return ( + + ); + } + // For ADMIN transactions, show the admin wallet label + return row.original.source_wallet?.label ?? "N/A"; + }, + }, + { + accessorKey: "destination_address", + header: "Destination", + cell: ({ row }) => { + const chain = row.original.chain; + const address = row.original.destination_address; + + const explorerUrl = getExplorerUrl(chain, undefined, address); + + return ( + + ); + }, + }, + { + accessorKey: "amount_usdc", + header: "Amount", + cell: ({ row }) => { + const amount = row.original.amount_usdc || row.original.amount || 0; + return `${amount.toLocaleString()} ${row.original.asset}`; + }, + }, + { + accessorKey: "transaction_type", + header: "Type", + cell: ({ row }) => { + const type = row.original.transaction_type; + // Using a Badge for consistency and readability + return type; + }, + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const status = row.original.status.toUpperCase(); + + // Define status-specific styling for better visibility in both themes + const getStatusStyle = () => { + switch (status) { + case "COMPLETE": + case "COMPLETED": + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100 border-green-300 dark:border-green-700"; + case "CONFIRMED": + return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100 border-blue-300 dark:border-blue-700"; + case "PENDING": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100 border-yellow-300 dark:border-yellow-700"; + case "FAILED": + return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100 border-red-300 dark:border-red-700"; + default: + return "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100 border-gray-300 dark:border-gray-700"; + } + }; + + return ( + + {status} + + ); + }, + }, + { + accessorKey: "created_at", + header: "Timestamp", + cell: ({ row }) => , + }, +]; \ No newline at end of file diff --git a/components/admin-transactions-table/table.tsx b/components/admin-transactions-table/table.tsx new file mode 100644 index 0000000..146c223 --- /dev/null +++ b/components/admin-transactions-table/table.tsx @@ -0,0 +1,117 @@ +/** + * 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 + */ + +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { useRealtimeAdminTransactions, AdminTransaction } from "@/hooks/use-realtime-admin-transactions"; + +interface AdminTransactionsTableProps { + columns: ColumnDef[]; + initialData: AdminTransaction[]; +} + +export function AdminTransactionsTable({ + columns, + initialData, +}: AdminTransactionsTableProps) { + // Use the custom hook to manage real-time data + const data = useRealtimeAdminTransactions(initialData); + + const table = useReactTable({ + data, + columns, + filterFns: { + dateBetween: () => true + }, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No transactions found. + + + )} + +
+
+
+ + +
+
+ ); +} \ No newline at end of file diff --git a/components/admin-wallets-table/balance-dialog.tsx b/components/admin-wallets-table/balance-dialog.tsx new file mode 100644 index 0000000..dbbb781 --- /dev/null +++ b/components/admin-wallets-table/balance-dialog.tsx @@ -0,0 +1,133 @@ +/** + * 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 + */ + +"use client"; + +import { useState, useEffect } from "react"; +import { Database } from "@/types/supabase"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Loader2 } from "lucide-react"; +import { getWalletBalance, TokenBalance } from "@/lib/actions/admin-wallets"; + +type Wallet = Database["public"]["Tables"]["admin_wallets"]["Row"]; + +interface BalanceDialogProps { + wallet: Wallet | null; + onClose: () => void; +} + +export function BalanceDialog({ wallet, onClose }: BalanceDialogProps) { + const [isLoading, setIsLoading] = useState(true); + const [balances, setBalances] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + if (wallet) { + setIsLoading(true); + setError(null); + getWalletBalance(wallet.circle_wallet_id).then((result) => { + if (result.error) { + setError(result.error); + } else { + setBalances(result.balances ?? []); + } + setIsLoading(false); + }); + } + }, [wallet]); + + return ( + !open && onClose()}> + + + Wallet Balance + + Balances for wallet:{" "} + {wallet?.label} + + +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : balances.length === 0 ? ( +
+ This wallet holds no balances. +
+ ) : ( +
+ + + + Asset + Blockchain + Amount + + + + {balances.map((balance) => { + // The API returns the amount as a string in its major unit (e.g., "29.99"). + // We just need to parse it as a number. No division is needed. + const formattedAmount = Number(balance.amount); + return ( + + + {balance.token.name} ({balance.token.symbol}) + + {balance.token.blockchain} + + {formattedAmount.toLocaleString(undefined, { + maximumFractionDigits: 6, + })} + + + ); + })} + +
+
+ )} +
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/components/admin-wallets-table/columns.tsx b/components/admin-wallets-table/columns.tsx new file mode 100644 index 0000000..c712d36 --- /dev/null +++ b/components/admin-wallets-table/columns.tsx @@ -0,0 +1,290 @@ +/** + * 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 + */ + +"use client"; + +import { useState } from "react"; +import { ColumnDef, Row, Table } from "@tanstack/react-table"; +import { Database } from "@/types/supabase"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { chainNameToId } from "@/lib/utils/chain-utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + ArrowUpDown, + MoreHorizontal, + Copy, + ArrowUp, + ArrowDown, +} from "lucide-react"; +import { toast } from "sonner"; +import { updateAdminWalletStatus } from "@/lib/actions/admin-wallets"; +import { ClientDate } from "@/components/ui/client-date"; + +type Wallet = Database["public"]["Tables"]["admin_wallets"]["Row"]; +type WalletStatus = Database["public"]["Enums"]["admin_wallet_status"]; +export type ConfirmableAction = "DISABLED" | "ARCHIVED"; + +export const CopyableCell = ({ + value, + href, +}: { + value: string; + href?: string; +}) => { + const [hasCopied, setHasCopied] = useState(false); + const [isTooltipOpen, setIsTooltipOpen] = useState(false); + + const copyToClipboard = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(value); + setHasCopied(true); + setIsTooltipOpen(true); + + setTimeout(() => { + setHasCopied(false); + setIsTooltipOpen(false); + }, 2000); + }; + + const displayText = `${value.slice(0, 4)}...${value.slice(-4)}`; + + const TextElement = href ? ( + e.stopPropagation()} + className="text-blue-500 hover:underline" + > + {displayText} + + ) : ( + {displayText} + ); + + return ( +
+ {TextElement} + + + + + + +

{hasCopied ? "Copied!" : "Copy"}

+
+
+
+
+ ); +}; + +const handleStatusUpdate = async (id: string, status: WalletStatus) => { + const promise = updateAdminWalletStatus(id, status); + toast.promise(promise, { + loading: `Updating status to ${status}...`, + success: "Wallet status updated successfully.", + error: (err) => `Failed to update status: ${err.message}`, + }); +}; + +const ActionsCell = ({ + row, + table, +}: { + row: Row; + table: Table; +}) => { + const wallet = row.original; + const openConfirmationDialog = table.options.meta?.openConfirmationDialog; + const openTransferDialog = table.options.meta?.openTransferDialog; + const openBalanceDialog = table.options.meta?.openBalanceDialog; + + return ( + + + + + + Actions + + openBalanceDialog?.(wallet)}> + Check Balance + + {wallet.status === "ENABLED" && ( + openTransferDialog?.(wallet)}> + Transfer Amount + + )} + {wallet.status !== "ENABLED" && ( + handleStatusUpdate(wallet.id, "ENABLED")} + > + Enable + + )} + {wallet.status !== "DISABLED" && ( + openConfirmationDialog?.(wallet, "DISABLED")} + > + Disable + + )} + {wallet.status !== "ARCHIVED" && ( + openConfirmationDialog?.(wallet, "ARCHIVED")} + > + Archive + + )} + + + ); +}; + +export const columns: ColumnDef[] = [ + { + accessorKey: "label", + header: ({ column }) => { + const sortDirection = column.getIsSorted(); + return ( + + ); + }, + cell: ({ row }) => { + const label = row.original.label; + return ( + + + +
{label}
+
+ +

{label}

+
+
+
+ ); + }, + }, + { + accessorKey: "circle_wallet_id", + header: "Circle Wallet ID", + cell: ({ row }) => , + }, + { + accessorKey: "address", + header: "Address", + cell: ({ row }) => { + const address = row.original.address; + const chain = row.original.chain; + + // Convert chain name to numeric ID for the utility function + const chainId = chain ? chainNameToId(chain) : undefined; + const { getExplorerUrl } = require("@/lib/utils/chain-utils"); + const explorerUrl = chainId + ? getExplorerUrl(chainId, undefined, address) + : `https://testnet.arcscan.app/address/${address}`; + + return ( + + ); + }, + }, + { + accessorKey: "chain", + header: "Chain", + cell: ({ row }) => ( +
{row.original.chain ?? "N/A"}
+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const status = row.original.status; + const variant: "default" | "secondary" | "destructive" = + status === "ENABLED" + ? "default" + : status === "DISABLED" + ? "secondary" + : "destructive"; + return {status}; + }, + }, + { + accessorKey: "created_at", + header: "Created At", + cell: ({ row }) => , + }, + { + accessorKey: "updated_at", + header: "Last Updated", + cell: ({ row }) => , + }, + { + id: "actions", + cell: ({ row, table }) => , + }, +]; \ No newline at end of file diff --git a/components/admin-wallets-table/table.tsx b/components/admin-wallets-table/table.tsx new file mode 100644 index 0000000..b326c63 --- /dev/null +++ b/components/admin-wallets-table/table.tsx @@ -0,0 +1,394 @@ +/** + * 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 + */ + +"use client"; + +import { useState, useMemo } from "react"; +import { useFormStatus } from "react-dom"; +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { PlusCircle, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { + createAdminWallet, + updateAdminWalletStatus, +} from "@/lib/actions/admin-wallets"; +import { AdminWalletsToolbar } from "@/components/admin-wallets-table/toolbar"; +import { Database } from "@/types/supabase"; +import { ConfirmableAction } from "@/components/admin-wallets-table/columns"; +import { TransferDialog } from "@/components/admin-wallets-table/transfer-dialog"; +import { BalanceDialog } from "@/components/admin-wallets-table/balance-dialog"; + +type Wallet = Database["public"]["Tables"]["admin_wallets"]["Row"]; + +const SUPPORTED_CHAINS = [ + { id: "ARC-TESTNET", name: "Arc Testnet" }, + { id: "AVAX-FUJI", name: "Avalanche Fuji" }, + { id: "BASE-SEPOLIA", name: "Base Sepolia" }, +]; + +interface AdminWalletsTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +function CreateWalletSubmitButton({ isFormValid }: { isFormValid: boolean }) { + const { pending } = useFormStatus(); + return ( + + ); +} + +export function AdminWalletsTable({ + columns, + data, +}: AdminWalletsTableProps) { + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = + useState([]); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + + const [newWalletLabel, setNewWalletLabel] = useState(""); + const [newWalletBlockchain, setNewWalletBlockchain] = useState(""); + + const [confirmationState, setConfirmationState] = useState<{ + wallet: Wallet; + action: ConfirmableAction; + } | null>(null); + + const [transferSourceWallet, setTransferSourceWallet] = + useState(null); + + const [walletForBalanceCheck, setWalletForBalanceCheck] = + useState(null); + + const confirmationDetails: Record< + ConfirmableAction, + { title: string; description: string; actionText: string } + > = { + DISABLED: { + title: "Are you sure you want to disable this wallet?", + description: + "This will prevent the wallet from being used for any new transactions. This action can be undone.", + actionText: "Yes, Disable", + }, + ARCHIVED: { + title: "Are you sure you want to archive this wallet?", + description: + "This action is permanent and cannot be undone. The wallet will be removed from the active list.", + actionText: "Yes, Archive", + }, + }; + + const table = useReactTable({ + data, + columns, + filterFns: { dateBetween: () => true }, + state: { sorting, columnFilters }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + meta: { + openConfirmationDialog: (wallet: Wallet, action: ConfirmableAction) => { + setConfirmationState({ wallet, action }); + }, + openTransferDialog: (wallet: Wallet) => { + setTransferSourceWallet(wallet); + }, + openBalanceDialog: (wallet: Wallet) => { + setWalletForBalanceCheck(wallet); + }, + }, + }); + + const handleConfirmAction = () => { + if (confirmationState) { + const { wallet, action } = confirmationState; + const promise = updateAdminWalletStatus(wallet.id, action); + toast.promise(promise, { + loading: `Updating status to ${action}...`, + success: "Wallet status updated successfully.", + error: (err) => `Failed to update status: ${err.message}`, + }); + } + setConfirmationState(null); + }; + + const otherWallets = useMemo( + () => (data as Wallet[]).filter((w) => w.id !== transferSourceWallet?.id), + [data, transferSourceWallet] + ); + + // We explicitly check that `newWalletBlockchain` is not an empty string. + // This ensures the expression always returns a true boolean. + const isCreateFormValid = useMemo(() => { + return newWalletLabel.trim().length >= 3 && newWalletBlockchain !== ""; + }, [newWalletLabel, newWalletBlockchain]); + + const handleCreateDialogOpenChange = (open: boolean) => { + setIsCreateDialogOpen(open); + if (!open) { + setNewWalletLabel(""); + setNewWalletBlockchain(""); + } + }; + + return ( +
+
+ + + + + + +
{ + const result = await createAdminWallet(formData); + if (result.error) { + toast.error("Creation Failed", { + description: result.error, + }); + } else { + toast.success("Wallet created successfully."); + handleCreateDialogOpenChange(false); + } + }} + > + + Create New Admin Wallet + + This will create a new Circle wallet for platform use. + + +
+
+ + setNewWalletLabel(e.target.value)} + /> +
+
+ + +
+
+ + + +
+
+
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No admin wallets found. + + + )} + +
+
+
+ + +
+ + !open && setConfirmationState(null)} + > + + + + {confirmationState && + confirmationDetails[confirmationState.action].title} + + + {confirmationState && + confirmationDetails[confirmationState.action].description} + + + + Cancel + + {confirmationState && + confirmationDetails[confirmationState.action].actionText} + + + + + + setTransferSourceWallet(null)} + /> + + setWalletForBalanceCheck(null)} + /> +
+ ); +} \ No newline at end of file diff --git a/components/admin-wallets-table/toolbar.tsx b/components/admin-wallets-table/toolbar.tsx new file mode 100644 index 0000000..a971d34 --- /dev/null +++ b/components/admin-wallets-table/toolbar.tsx @@ -0,0 +1,64 @@ +/** + * 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 + */ + +"use client"; + +import { Table } from "@tanstack/react-table"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { XIcon } from "lucide-react"; + +interface AdminWalletsToolbarProps { + table: Table; +} + +export function AdminWalletsToolbar({ + table, +}: AdminWalletsToolbarProps) { + const isFiltered = table.getState().columnFilters.length > 0; + + return ( +
+
+ + table + .getColumn("circle_wallet_id") + ?.setFilterValue(event.target.value) + } + className="h-9 w-[150px] lg:w-[250px]" + /> + {isFiltered && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/components/admin-wallets-table/transfer-dialog.tsx b/components/admin-wallets-table/transfer-dialog.tsx new file mode 100644 index 0000000..92fbe01 --- /dev/null +++ b/components/admin-wallets-table/transfer-dialog.tsx @@ -0,0 +1,261 @@ +/** + * 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 + */ + +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { Database } from "@/types/supabase"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; +import { transferFromAdminWallet, transferFromAdminWalletCCTP } from "@/lib/actions/admin-wallets"; +import { Loader2, AlertTriangle } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +// The Wallet type now correctly reflects that `chain` can be `string | null`, +// matching the database schema. +type Wallet = Database["public"]["Tables"]["admin_wallets"]["Row"]; + +interface TransferDialogProps { + sourceWallet: Wallet | null; + otherWallets: Wallet[]; + onClose: () => void; +} + +export function TransferDialog({ + sourceWallet, + otherWallets, + onClose, +}: TransferDialogProps) { + const [amount, setAmount] = useState(""); + const [destinationType, setDestinationType] = useState< + "existing" | "custom" + >("existing"); + const [selectedAddress, setSelectedAddress] = useState(""); + const [customAddress, setCustomAddress] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isCrossChain, setIsCrossChain] = useState(false); + + useEffect(() => { + if (!sourceWallet) { + setAmount(""); + setDestinationType("existing"); + setSelectedAddress(""); + setCustomAddress(""); + setIsCrossChain(false); + } + }, [sourceWallet]); + + useEffect(() => { + if (destinationType === "existing" && selectedAddress && sourceWallet) { + const destinationWallet = otherWallets.find( + (wallet) => wallet.address === selectedAddress + ); + // A cross-chain transfer is only possible if both wallets have a chain. + if (destinationWallet && sourceWallet.chain && destinationWallet.chain) { + setIsCrossChain(sourceWallet.chain !== destinationWallet.chain); + } else { + setIsCrossChain(false); + } + } else { + setIsCrossChain(false); + } + }, [selectedAddress, sourceWallet, otherWallets, destinationType]); + + const isFormValid = useMemo(() => { + const isAmountValid = Number(amount) > 0; + const isDestinationValid = + (destinationType === "existing" && selectedAddress !== "") || + (destinationType === "custom" && customAddress.trim() !== ""); + return isAmountValid && isDestinationValid; + }, [amount, destinationType, selectedAddress, customAddress]); + + const handleTransfer = async () => { + if (!isFormValid) { + toast.error("Missing or invalid information", { + description: + "Please fill out all fields with valid values to proceed.", + }); + return; + } + + const destination = + destinationType === "existing" ? selectedAddress : customAddress; + + setIsSubmitting(true); + + try { + if (isCrossChain) { + console.log("Initiating CCTP Cross-Chain Transfer..."); + toast.info("Cross-chain transfer detected", { + description: "This will be handled by Circle's CCTP.", + }); + } + + const transferArguments: [string, string, string] = [ + sourceWallet!.circle_wallet_id, + destination, + amount + ]; + + const result = isCrossChain + ? await transferFromAdminWalletCCTP(...transferArguments) + : await transferFromAdminWallet(...transferArguments); + + if (result.error) { + toast.error("Transfer Failed", { description: result.error }); + } else { + toast.success("Transfer Submitted Successfully", { + description: `Transaction ID: ${result.transactionId?.slice( + 0, + 15 + )}...`, + }); + onClose(); + } + } catch { + toast.error("An unexpected error occurred."); + } finally { + setIsSubmitting(false); + } + }; + + return ( + !open && onClose()}> + + + Transfer Amount + + Transfer funds from{" "} + {sourceWallet?.label} + {sourceWallet?.chain && ` (${sourceWallet.chain})`}. + + +
+ {isCrossChain && ( + + + Cross-Chain Transfer + + You are sending USDC to a wallet on a different blockchain. This + will use Circle's CCTP. + + + )} +
+ + setAmount(e.target.value)} + disabled={isSubmitting} + /> +
+ + setDestinationType(value) + } + className="space-y-2" + disabled={isSubmitting} + > +
+ + +
+ {/*
+ + +
*/} +
+ {destinationType === "existing" ? ( + + ) : ( + setCustomAddress(e.target.value)} + disabled={isSubmitting} + /> + )} +
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/components/auth-button.tsx b/components/auth-button.tsx index 96f6a53..6b25f5c 100644 --- a/components/auth-button.tsx +++ b/components/auth-button.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -20,21 +20,44 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { createClient } from "@/lib/supabase/server"; import { LogoutButton } from "@/components/logout-button"; +import { CreditsBadge } from "@/components/credits-badge"; export async function AuthButton() { const supabase = await createClient(); - // You can also use getUser() which will be slower. - const { data } = await supabase.auth.getClaims(); + const { + data: { user }, + } = await supabase.auth.getUser(); - const user = data?.claims; + if (user) { + // Check if the logged-in user is the admin. + const isAdmin = user.email === 'admin@admin.com'; - return user ? ( -
- Hey, {user.email}! - -
- ) : ( + // Only fetch credits if the user is NOT the admin. + let initialCredits = 0; + if (!isAdmin) { + const { data: creditsData } = await supabase + .from("credits") + .select("credits") + .eq("user_id", user.id) + .single(); + initialCredits = creditsData?.credits ?? 0; + } + + return ( +
+ Hey, {user.email}! + + {!isAdmin && ( + + )} + + +
+ ); + } + + return (
); -} +} \ No newline at end of file diff --git a/components/connect-wallet-dialog.tsx b/components/connect-wallet-dialog.tsx deleted file mode 100644 index a764bb3..0000000 --- a/components/connect-wallet-dialog.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Copyright 2026 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 - */ - -"use client"; - -import React, { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { useConnect } from "wagmi"; -import { Loader2 } from "lucide-react"; -import { createClient } from "@/lib/supabase/client"; - -export function ConnectDialog({ children }: { children: React.ReactNode }) { - const { isPending } = useConnect(); - const [open, setOpen] = useState(false); - const [isCreatingCircleWallet, setIsCreatingCircleWallet] = useState(false); - const [circleWalletError, setCircleWalletError] = useState( - null - ); - - const handleCreateCircleWallet = async () => { - setIsCreatingCircleWallet(true); - setCircleWalletError(null); - const supabase = createClient(); - - try { - const { - data: { user }, - error: authError, - } = await supabase.auth.getUser(); - - if (authError || !user || !user.email) { - throw new Error("User not authenticated. Please sign in."); - } - - // 1. Create Wallet Set - const walletSetResponse = await fetch("/api/wallet-set", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ entityName: user.email }), - }); - if (!walletSetResponse.ok) { - const { error } = await walletSetResponse.json(); - throw new Error(error || "Failed to create wallet set."); - } - const createdWalletSet = await walletSetResponse.json(); - - // 2. Create Wallet - const walletResponse = await fetch("/api/wallet", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ walletSetId: createdWalletSet.id }), - }); - if (!walletResponse.ok) { - const { error } = await walletResponse.json(); - throw new Error(error || "Failed to create wallet."); - } - const createdWallet = await walletResponse.json(); - - // 3. Insert wallet into Supabase, linking it directly to the auth user - const { error: insertError } = await supabase.from("wallets").insert({ - user_id: user.id, // Use the user_id from auth.users - circle_wallet_id: createdWallet.id, - wallet_set_id: createdWalletSet.id, - wallet_address: createdWallet.address, - }); - - if (insertError) { - console.error("Supabase insert error:", insertError); - throw new Error("Failed to save wallet to your profile."); - } - - // Success - setOpen(false); - window.location.reload(); // Reload to reflect the new wallet in the dashboard - } catch (error: any) { - setCircleWalletError(error.message); - } finally { - setIsCreatingCircleWallet(false); - } - }; - - return ( - - {children} - - - Connect a Wallet - -
- - - {circleWalletError && ( -

- {circleWalletError} -

- )} -
-
-
- ); -} \ No newline at end of file diff --git a/components/connect-wallet.tsx b/components/connect-wallet.tsx deleted file mode 100644 index 9d5b1f3..0000000 --- a/components/connect-wallet.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Copyright 2026 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 - */ - -"use client"; - -import { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { useAccount, useDisconnect, useConnections } from "wagmi"; -import { ConnectDialog } from "@/components/connect-wallet-dialog"; -import { createClient } from "@/lib/supabase/client"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Loader2 } from "lucide-react"; - -type CircleWallet = { - wallet_address: string; -}; - -export function ConnectWallet({ onAccountsChange }: { onAccountsChange?: (accounts: string[]) => void }) { - const { isConnected } = useAccount(); - const { disconnect } = useDisconnect(); - const connections = useConnections(); - const [circleWallets, setCircleWallets] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isCreatingCircleWallet, setIsCreatingCircleWallet] = useState(false); - - const handleCreateCircleWallet = async () => { - setIsCreatingCircleWallet(true); - const supabase = createClient(); - - try { - const { - data: { user }, - error: authError, - } = await supabase.auth.getUser(); - - if (authError || !user || !user.email) { - throw new Error("User not authenticated. Please sign in."); - } - - // 1. Create Wallet Set - const walletSetResponse = await fetch("/api/wallet-set", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ entityName: user.email }), - }); - if (!walletSetResponse.ok) { - const { error } = await walletSetResponse.json(); - throw new Error(error || "Failed to create wallet set."); - } - const createdWalletSet = await walletSetResponse.json(); - - // 2. Create Wallet - const walletResponse = await fetch("/api/wallet", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ walletSetId: createdWalletSet.id }), - }); - if (!walletResponse.ok) { - const { error } = await walletResponse.json(); - throw new Error(error || "Failed to create wallet."); - } - const createdWallet = await walletResponse.json(); - - // 3. Insert wallet into Supabase, linking it directly to the auth user - const { error: insertError } = await supabase.from("wallets").insert({ - user_id: user.id, // Use the user_id from auth.users - circle_wallet_id: createdWallet.id, - wallet_set_id: createdWalletSet.id, - wallet_address: createdWallet.address, - }); - - if (insertError) { - console.error("Supabase insert error:", insertError); - throw new Error("Failed to save wallet to your profile."); - } - - window.location.reload(); - } finally { - setIsCreatingCircleWallet(false); - } - }; - - useEffect(() => { - const fetchCircleWallet = async () => { - setIsLoading(true); - const supabase = createClient(); - const { data: { user } } = await supabase.auth.getUser(); - if (user) { - const { data, error } = await supabase - .from("wallets") - .select("wallet_address") - .eq("user_id", user.id); - if (data && !error) { - setCircleWallets(data); - } - } - setIsLoading(false); - }; - fetchCircleWallet(); - }, []); - - useEffect(() => { - const wagmiAddresses = connections.map(conn => conn.accounts).flat(); - const circleAddresses = circleWallets.map(w => w.wallet_address); - const allAddresses = [...wagmiAddresses, ...circleAddresses]; - const uniqueAddresses = Array.from(new Set(allAddresses)); - onAccountsChange?.(uniqueAddresses); - }, [connections, circleWallets, onAccountsChange]); - - const hasWagmiWallet = isConnected && connections.length > 0; - const hasCircleWallet = circleWallets.length > 0; - const hasAnyWallet = hasWagmiWallet || hasCircleWallet; - - if (isLoading) { - return ; - } - - if (!hasAnyWallet) { - return ( - - ) - } - - return ( -
-
-
- Connected Wallets: - - - -
-
    - {/* Render Circle Wallets */} - {circleWallets.map((wallet) => ( -
  • - {wallet.wallet_address} -
  • - ))} -
-
- {/* Only show Disconnect button if a wagmi wallet is connected */} - {hasWagmiWallet && ( - - )} -
- ); -} \ No newline at end of file diff --git a/components/credits-badge.tsx b/components/credits-badge.tsx new file mode 100644 index 0000000..79e3b5d --- /dev/null +++ b/components/credits-badge.tsx @@ -0,0 +1,97 @@ +/** + * 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 + */ + +"use client"; + +import { useEffect, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { createClient } from "@/lib/supabase/client"; + +interface CreditsBadgeProps { + initialCredits: number; + userId: string; +} + +// This component receives the initial credit balance from the server, +// then subscribes to real-time updates to keep the display in sync. +export function CreditsBadge({ initialCredits, userId }: CreditsBadgeProps) { + const [credits, setCredits] = useState(initialCredits); + const [supabase] = useState(() => createClient()); + + // Format the number for better readability (e.g., 1,000.50) + const formattedBalance = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(credits); + + useEffect(() => { + // Fetch current credits from the database + const fetchCredits = async () => { + const { data, error } = await supabase + .from("credits") + .select("credits") + .eq("user_id", userId) + .single(); + + if (!error && data) { + console.log("Fetched credits:", data.credits); + setCredits(data.credits); + } else if (error) { + console.error("Error fetching credits:", error); + } + }; + + // Create a channel for real-time updates + const channel = supabase + .channel(`credits-user-${userId}`) + .on( + "postgres_changes", + { + event: "*", // Listen to all events (INSERT, UPDATE, DELETE) + schema: "public", + table: "credits", + filter: `user_id=eq.${userId}`, // Listen only to changes for this user + }, + (payload) => { + // When an update is received, get the new credits value + if (payload.new && typeof payload.new === "object" && "credits" in payload.new) { + const newCredits = payload.new.credits as number; + console.log("Real-time credit event received:", payload.eventType, "New credits:", newCredits); + // Update the component's state to re-render with the new value + setCredits(newCredits); + } else { + console.warn("Received payload without expected credits field:", payload); + } + } + ) + .subscribe((status) => { + console.log("Credits subscription status:", status); + }); + + // Poll for updates every 10 seconds as a fallback + const pollInterval = setInterval(fetchCredits, 10000); + + // Cleanup function: Unsubscribe from the channel when the component unmounts + return () => { + supabase.removeChannel(channel); + clearInterval(pollInterval); + }; + }, [userId, supabase]); // Re-run the effect only if the userId changes + + return Credits: {formattedBalance}; +} \ No newline at end of file diff --git a/components/dashboard-skeleton.tsx b/components/dashboard-skeleton.tsx deleted file mode 100644 index 97fddde..0000000 --- a/components/dashboard-skeleton.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Copyright 2026 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 { - Card, - CardContent, - CardHeader, -} from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; - -export function DashboardSkeleton() { - return ( -
- {/* Top Row Skeleton */} -
- {/* Wallet Setup Skeleton */} - - - - - - - -
- - -
-
-
- - {/* Balance Overview Skeleton */} - - - - - - -
- - - -
-
-
-
- - {/* Actions Card Skeleton */} - - - - - - - - - - - - {/* Transaction History Skeleton */} - - - - - - - - - - -
- ); -} \ No newline at end of file diff --git a/components/deposit-form.tsx b/components/deposit-form.tsx deleted file mode 100644 index 9d808e2..0000000 --- a/components/deposit-form.tsx +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Copyright 2026 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 - */ - -"use client"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { SupportedChain, SUPPORTED_CHAINS } from "@/lib/chain-config"; - -interface DepositFormProps { - onSuccess: () => void; -} - -export function DepositForm({ onSuccess }: DepositFormProps) { - const [depositChain, setDepositChain] = useState("arcTestnet"); - const [depositAmount, setDepositAmount] = useState(""); - const [depositLoading, setDepositLoading] = useState(false); - const [depositSuccess, setDepositSuccess] = useState(null); - const [depositError, setDepositError] = useState(null); - - const handleDeposit = async () => { - if (!depositAmount) { - setDepositError("Please provide private key and amount"); - return; - } - setDepositLoading(true); - setDepositSuccess(null); - setDepositError(null); - try { - const response = await fetch("/api/gateway/deposit", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - chain: depositChain, - amount: depositAmount, - }), - }); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Deposit failed"); - } - const data = await response.json(); - setDepositSuccess(`Deposit successful! Tx: ${data.txHash}`); - setDepositAmount(""); - setTimeout(() => { - onSuccess(); - window.location.reload(); - }, 2000); - } catch (err: any) { - setDepositError(err.message); - } finally { - setDepositLoading(false); - } - }; - - return ( -
-
- - -
-
- - setDepositAmount(e.target.value)} - disabled={depositLoading} - /> - {depositAmount && parseFloat(depositAmount) <= 0 && ( -

Amount must be greater than 0

- )} -
- - {depositSuccess && ( -

{depositSuccess}

- )} - {depositError &&

{depositError}

} -
- ); -} diff --git a/components/env-var-warning.tsx b/components/env-var-warning.tsx index 0ef4ff3..000d653 100644 --- a/components/env-var-warning.tsx +++ b/components/env-var-warning.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. diff --git a/components/forgot-password-form.tsx b/components/forgot-password-form.tsx new file mode 100644 index 0000000..4632cc5 --- /dev/null +++ b/components/forgot-password-form.tsx @@ -0,0 +1,123 @@ +/** + * 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 + */ + +"use client"; + +import { cn } from "@/lib/utils"; +import { createClient } from "@/lib/supabase/client"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import Link from "next/link"; +import { useState } from "react"; + +export function ForgotPasswordForm({ + className, + ...props +}: React.ComponentPropsWithoutRef<"div">) { + const [email, setEmail] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const handleForgotPassword = async (e: React.FormEvent) => { + e.preventDefault(); + const supabase = createClient(); + setIsLoading(true); + setError(null); + + try { + // The url which will be included in the email. This URL needs to be configured in your redirect URLs in the Supabase dashboard at https://supabase.com/dashboard/project/_/auth/url-configuration + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${window.location.origin}/auth/update-password`, + }); + if (error) throw error; + setSuccess(true); + } catch (error: unknown) { + setError(error instanceof Error ? error.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {success ? ( + + + Check Your Email + Password reset instructions sent + + +

+ If you registered using your email and password, you will receive + a password reset email. +

+
+
+ ) : ( + + + Reset Your Password + + Type in your email and we'll send you a link to reset your + password + + + +
+
+
+ + setEmail(e.target.value)} + /> +
+ {error &&

{error}

} + +
+
+ Already have an account?{" "} + + Login + +
+
+
+
+ )} +
+ ); +} diff --git a/components/hero.tsx b/components/hero.tsx index 2143d9a..271c1b0 100644 --- a/components/hero.tsx +++ b/components/hero.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -19,12 +19,26 @@ export function Hero() { return (
-

Supabase and Next.js Starter Template

+

Arc Commerce

- Wallet with unified cross-chain{" "} - USDC balances{" "} + Platform credit purchases using{" "} + + USDC + {" "} and{" "} - transfers + + Circle Wallets +

); diff --git a/components/login-form.tsx b/components/login-form.tsx index 9ac9b16..93891a1 100644 --- a/components/login-form.tsx +++ b/components/login-form.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -91,6 +91,12 @@ export function LoginForm({
+ + Forgot your password? +
+ + + + + + + + + + ); +} diff --git a/components/sign-up-form.tsx b/components/sign-up-form.tsx index 8c090a8..e408261 100644 --- a/components/sign-up-form.tsx +++ b/components/sign-up-form.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. diff --git a/components/supabase-logo.tsx b/components/supabase-logo.tsx new file mode 100644 index 0000000..d512f17 --- /dev/null +++ b/components/supabase-logo.tsx @@ -0,0 +1,120 @@ +/** + * 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 + */ + +export function SupabaseLogo() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/components/theme-switcher.tsx b/components/theme-switcher.tsx index 32071a2..449b56f 100644 --- a/components/theme-switcher.tsx +++ b/components/theme-switcher.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. diff --git a/components/transaction-history-table.tsx b/components/transaction-history-table.tsx new file mode 100644 index 0000000..60b5717 --- /dev/null +++ b/components/transaction-history-table.tsx @@ -0,0 +1,224 @@ +/** + * 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 + */ + +"use client"; + +import { useState, useEffect } from "react"; +import { + ColumnFiltersState, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, + FilterFn, +} from "@tanstack/react-table"; +import { startOfDay, endOfDay, isValid } from "date-fns"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { createClient } from "@/lib/supabase/client"; +import type { TransactionRow } from "@/components/user-transactions-table/columns"; +import { DataTable } from "@/components/user-transactions-table/table"; +import { columns } from "@/components/user-transactions-table/columns"; + +interface ApiTransaction { + id: string; + created_at: string; + credit_amount: string | number; + amount_usdc: string | number; + fee_usdc: string | number; + status: "pending" | "confirmed" | "complete" | "failed"; + chain: string; + tx_hash: string; +} + +interface TransactionHistoryProps { + showHeader?: boolean; // optionally render heading / back button + backHref?: string; // backlink destination if header shown + className?: string; +} + +const dateBetweenFilterFn: FilterFn = ( + row, + columnId, + filterValue: [Date | undefined, Date | undefined] +) => { + if (!Array.isArray(filterValue)) return true; + + const [from, to] = filterValue; + if (!from && !to) return true; + + const rowDateRaw = row.getValue(columnId); + if (!rowDateRaw) return false; + + const date = new Date(rowDateRaw); + if (!isValid(date)) return false; + + const fromDate = from ? startOfDay(from) : null; + const toDate = to ? endOfDay(to) : null; + + if (fromDate && toDate) return date >= fromDate && date <= toDate; + if (fromDate) return date >= fromDate; + if (toDate) return date <= toDate; + return true; +}; + +export function TransactionHistory({ + showHeader = false, + backHref = "/dashboard", + className = "", +}: TransactionHistoryProps) { + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = + useState([]); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + const supabase = createClient(); + + async function load() { + try { + const resp = await fetch("/api/transactions"); + const json = await resp.json(); + if (cancelled) return; + const rows: TransactionRow[] = ((json.data as ApiTransaction[]) || []).map((t) => ({ + id: t.id, + date: new Date(t.created_at), + credits: Number(t.credit_amount), + usdcPaid: Number(t.amount_usdc), + fee: Number(t.fee_usdc), + status: t.status, + network: t.chain, + txHash: t.tx_hash, + })); + setData(rows); + } catch (error) { + console.error("Failed to load transactions:", error); + } finally { + if (!cancelled) setLoading(false); + } + } + + load(); + + // Realtime subscription (INSERT + UPDATE on transactions) + let channel: ReturnType | null = null; + + supabase.auth.getUser().then(({ data: { user } }) => { + if (!user || cancelled) return; + + channel = supabase + .channel("transactions-realtime") + .on( + "postgres_changes", + { event: "INSERT", schema: "public", table: "transactions" }, + (payload: { new: ApiTransaction }) => { + const t = payload.new; + setData((prev) => { + if (prev.find((p) => p.id === t.id)) return prev; + const row: TransactionRow = { + id: t.id, + date: new Date(t.created_at), + credits: Number(t.credit_amount), + usdcPaid: Number(t.amount_usdc), + fee: Number(t.fee_usdc), + status: t.status, + network: t.chain, + txHash: t.tx_hash, + }; + return [row, ...prev]; + }); + } + ) + .on( + "postgres_changes", + { event: "UPDATE", schema: "public", table: "transactions" }, + (payload: { new: ApiTransaction }) => { + const t = payload.new; + setData((prev) => + prev.map((p) => + p.id === t.id + ? { + id: t.id, + date: new Date(t.created_at), + credits: Number(t.credit_amount), + usdcPaid: Number(t.amount_usdc), + fee: Number(t.fee_usdc), + status: t.status, + network: t.chain, + txHash: t.tx_hash, + } + : p + ) + ); + } + ) + .subscribe(); + }).catch((error) => { + console.error("Failed to setup realtime subscription:", error); + }); + + return () => { + cancelled = true; + if (channel) { + supabase.removeChannel(channel); + } + }; + }, []); + + const table = useReactTable({ + data, + columns, + filterFns: { dateBetween: dateBetweenFilterFn }, + state: { sorting, columnFilters }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + return ( +
+ {showHeader && ( +
+ +

Credit Purchases

+

+ View and manage your transaction history. +

+
+ )} + {loading ? ( +
Loading transactions...
+ ) : ( + + )} +
+ ); +} diff --git a/components/transaction-history.tsx b/components/transaction-history.tsx deleted file mode 100644 index 47ae07b..0000000 --- a/components/transaction-history.tsx +++ /dev/null @@ -1,430 +0,0 @@ -/** - * Copyright 2026 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 - */ - -"use client"; - -import { useEffect, useState, useMemo } from "react"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Badge } from "@/components/ui/badge"; -import { ArrowUpDown, ArrowUp, ArrowDown, ExternalLink } from "lucide-react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; - -type TransactionType = "deposit" | "transfer" | "unify"; -type TransactionStatus = "pending" | "success" | "failed"; - -interface Transaction { - id: string; - user_id: string; - chain: string; - tx_type: TransactionType; - amount: number; - tx_hash: string | null; - gateway_wallet_address: string | null; - destination_chain: string | null; - status: TransactionStatus; - reason: string | null; - created_at: string; -} - -// Chain configuration for explorer links -const CHAIN_EXPLORERS: Record = { - arcTestnet: "https://testnet.arcscan.app", - baseSepolia: "https://sepolia.basescan.org/", - avalancheFuji: "https://testnet.snowtrace.io/", -}; - -const CHAIN_NAMES: Record = { - arcTestnet: "Arc Testnet", - baseSepolia: "Base Sepolia", - avalancheFuji: "Avalanche Fuji", -}; - -export function TransactionHistory() { - const [isMounted, setIsMounted] = useState(false); - - const [transactions, setTransactions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // Filter and sort state - const [searchTerm, setSearchTerm] = useState(""); - const [typeFilter, setTypeFilter] = useState("all"); - const [statusFilter, setStatusFilter] = useState("all"); - const [sortOrder, setSortOrder] = useState<"none" | "asc" | "desc">("desc"); - const [currentPage, setCurrentPage] = useState(1); - const rowsPerPage = 10; - - useEffect(() => { - setIsMounted(true); - }, []); - - useEffect(() => { - async function fetchTransactions() { - setLoading(true); - try { - const response = await fetch("/api/transactions"); - if (!response.ok) { - throw new Error(`Error: ${response.statusText}`); - } - const data = await response.json(); - setTransactions(data); - } catch (err: any) { - setError(err.message); - } finally { - setLoading(false); - } - } - - // Only fetch if we are mounted and connected - if (isMounted) { - fetchTransactions(); - } - }, [isMounted]); - - // Filter and sort transactions - const filteredAndSortedTransactions = useMemo(() => { - let filtered = transactions.filter((tx) => { - // Search filter (tx hash, chain, or destination chain) - const searchLower = searchTerm.toLowerCase(); - const matchesSearch = - !searchTerm || - tx.tx_hash?.toLowerCase().includes(searchLower) || - tx.chain.toLowerCase().includes(searchLower) || - tx.destination_chain?.toLowerCase().includes(searchLower); - - // Type filter - const matchesType = typeFilter === "all" || tx.tx_type === typeFilter; - - // Status filter - const matchesStatus = - statusFilter === "all" || tx.status === statusFilter; - - return matchesSearch && matchesType && matchesStatus; - }); - - // Sort by date - if (sortOrder !== "none") { - filtered.sort((a, b) => { - const dateA = new Date(a.created_at).getTime(); - const dateB = new Date(b.created_at).getTime(); - return sortOrder === "asc" ? dateA - dateB : dateB - dateA; - }); - } - - return filtered; - }, [transactions, searchTerm, typeFilter, statusFilter, sortOrder]); - - const totalPages = Math.ceil( - filteredAndSortedTransactions.length / rowsPerPage - ); - const paginatedTransactions = filteredAndSortedTransactions.slice( - (currentPage - 1) * rowsPerPage, - currentPage * rowsPerPage - ); - - const handleSort = () => { - if (sortOrder === "none") setSortOrder("desc"); - else if (sortOrder === "desc") setSortOrder("asc"); - else setSortOrder("none"); - }; - - const SortIcon = () => { - if (sortOrder === "asc") - return ; - if (sortOrder === "desc") - return ; - return ; - }; - - const getStatusBadge = (status: TransactionStatus) => { - switch (status) { - case "success": - return ( - - Success - - ); - case "failed": - return ( - - Failed - - ); - case "pending": - return ( - - Pending - - ); - default: - return {status}; - } - }; - - const getExplorerLink = (tx: Transaction) => { - // For transfers, the tx_hash is the mint transaction on the destination chain - // For deposits, the tx_hash is on the source chain - const chain = tx.tx_type === "transfer" && tx.destination_chain - ? tx.destination_chain - : tx.chain; - - const explorerBase = CHAIN_EXPLORERS[chain]; - if (!explorerBase || !tx.tx_hash) return null; - return `${explorerBase}tx/${tx.tx_hash}`; - }; - - const formatChainName = (chain: string) => { - return CHAIN_NAMES[chain] || chain; - }; - - const truncateHash = (hash: string) => { - if (!hash) return "N/A"; - return `${hash.slice(0, 6)}...${hash.slice(-4)}`; - }; - - // Reset to page 1 when filters change - useEffect(() => { - setCurrentPage(1); - }, [searchTerm, typeFilter, statusFilter]); - - // This ensures the initial client render matches the server render (which is always disconnected state). - if (!isMounted) { - return ( - - - Transaction History - Please connect your wallet to view your transactions - - - ); - } - - return ( - - - Transaction History - - View all your deposit and transfer transactions with detailed status information - - - -
- {/* Filters */} -
- setSearchTerm(e.target.value)} - className="sm:max-w-xs" - /> - - -
- - {/* Table */} -
- - - - Type - Chain(s) - Amount - Status - Transaction Hash - - - - - - - {loading ? ( - Array.from({ length: 5 }).map((_, i) => ( - - - - - - )) - ) : error ? ( - - - Error: {error} - - - ) : paginatedTransactions.length > 0 ? ( - paginatedTransactions.map((tx) => ( - - - {tx.tx_type} - - -
-
{formatChainName(tx.chain)}
- {tx.destination_chain && ( -
- → {formatChainName(tx.destination_chain)} -
- )} -
-
- - {tx.amount ? `${tx.amount.toFixed(2)} USDC` : "N/A"} - - - {/* 2. Updated Status Cell with Tooltip Logic */} - {tx.status === "failed" && tx.reason ? ( - - - - {getStatusBadge(tx.status)} - - -

- {tx.reason} -

-
-
-
- ) : ( - getStatusBadge(tx.status) - )} -
- - {tx.tx_hash ? ( - - {truncateHash(tx.tx_hash)} - - - ) : ( - N/A - )} - - - {new Date(tx.created_at).toLocaleString()} - -
- )) - ) : ( - - - No transactions found. - - - )} -
-
-
- - {/* Pagination */} - {totalPages > 0 && ( -
-
- Showing {(currentPage - 1) * rowsPerPage + 1} to{" "} - {Math.min( - currentPage * rowsPerPage, - filteredAndSortedTransactions.length - )}{" "} - of {filteredAndSortedTransactions.length} transactions -
-
- - - Page {currentPage} of {totalPages} - - -
-
- )} -
-
-
- ); -} \ No newline at end of file diff --git a/components/transfer-form.tsx b/components/transfer-form.tsx deleted file mode 100644 index ab69f67..0000000 --- a/components/transfer-form.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Copyright 2026 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 - */ - -"use client"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { AlertCircleIcon } from "lucide-react"; -import { SupportedChain, SUPPORTED_CHAINS, CHAIN_NAMES, NATIVE_TOKENS } from "@/lib/chain-config"; - -interface TransferFormProps { - onSuccess: () => void; -} - -export function TransferForm({ onSuccess }: TransferFormProps) { - const [sourceChain, setSourceChain] = useState("arcTestnet"); - const [destinationChain, setDestinationChain] = useState("baseSepolia"); - const [transferAmount, setTransferAmount] = useState(""); - const [recipientAddress, setRecipientAddress] = useState(""); - const [transferLoading, setTransferLoading] = useState(false); - const [transferSuccess, setTransferSuccess] = useState(null); - const [transferError, setTransferError] = useState(null); - - const handleTransfer = async () => { - if (!transferAmount) { - setTransferError("Please provide private key and amount"); - return; - } - - if (sourceChain === destinationChain) { - setTransferError("Source and destination chains must be different"); - return; - } - - setTransferLoading(true); - setTransferSuccess(null); - setTransferError(null); - try { - const response = await fetch("/api/gateway/transfer", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sourceChain, - destinationChain, - amount: transferAmount, - recipientAddress: recipientAddress || undefined, - }), - }); - if (!response.ok) { - const error = await response.json(); - const errorMessage = error.error || "Transfer failed"; - - // Parse and format common error messages for better UX - if ( - errorMessage.includes("insufficient funds for transfer") || - errorMessage.includes("exceeds the balance of the account") - ) { - // This is a gas fee issue on destination chain - const chainName = CHAIN_NAMES[destinationChain]; - const nativeToken = NATIVE_TOKENS[destinationChain]; - throw new Error( - `Insufficient gas funds: You need ${nativeToken} on ${chainName} to pay for the minting transaction. ` + - `Please add some ${nativeToken} to your wallet on ${chainName} and try again.` - ); - } else if ( - errorMessage.includes("Insufficient balance") || - errorMessage.includes("insufficient balance") - ) { - throw new Error( - "Insufficient Gateway balance: You don't have enough USDC in your Gateway balance for this transfer." - ); - } else if ( - errorMessage.includes("Invalid address") || - errorMessage.includes("invalid recipient") - ) { - throw new Error( - "Invalid address: Please check the recipient address and try again." - ); - } else if (errorMessage.includes("Gateway API error")) { - // Extract the actual API error message - const apiErrorMatch = errorMessage.match(/Gateway API error: \d+ - (.+)/); - if (apiErrorMatch) { - throw new Error(`Transfer failed: ${apiErrorMatch[1]}`); - } - throw new Error(errorMessage); - } else { - throw new Error(errorMessage); - } - } - const data = await response.json(); - setTransferSuccess(`Transfer successful! Mint Tx: ${data.mintTxHash}`); - setTransferAmount(""); - setTimeout(() => { - onSuccess(); - window.location.reload(); - }, 2000); - } catch (err: any) { - // Ensure error message is always a string and doesn't break the UI - const errorMessage = - err?.message || "An unexpected error occurred. Please try again."; - setTransferError(errorMessage); - } finally { - setTransferLoading(false); - } - }; - - return ( -
-
-
- - -
-
- - -
-
- - - Gas Fees Required - - You need native tokens on the destination chain to pay for gas fees - when minting. - - -
- - setTransferAmount(e.target.value)} - disabled={transferLoading} - /> - {transferAmount && parseFloat(transferAmount) <= 0 && ( -

Amount must be greater than 0

- )} -
-
- - setRecipientAddress(e.target.value)} - disabled={transferLoading} - /> -
- - {transferSuccess && ( -

{transferSuccess}

- )} - {transferError &&

{transferError}

} -
- ); -} diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..1ca91fd --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,175 @@ +/** + * 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 + */ + +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx index d41c854..7f1bb95 100644 --- a/components/ui/alert.tsx +++ b/components/ui/alert.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -28,7 +28,7 @@ const alertVariants = cva( variant: { default: "bg-card text-card-foreground", destructive: - "text-destructive bg-card [&>svg]:text-current data-[slot=alert-description]:*:text-destructive/90", + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", }, }, defaultVariants: { diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx index b06ec50..2e30b99 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 5079b10..f6f1b3c 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx new file mode 100644 index 0000000..6302922 --- /dev/null +++ b/components/ui/calendar.tsx @@ -0,0 +1,231 @@ +/** + * 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 + */ + +"use client" + +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "flex gap-4 flex-col md:flex-row relative", + defaultClassNames.months + ), + month: cn("flex flex-col w-full gap-4", defaultClassNames.month), + nav: cn( + "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_next + ), + month_caption: cn( + "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute bg-popover inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", + defaultClassNames.weekday + ), + week: cn("flex w-full mt-2", defaultClassNames.week), + week_number_header: cn( + "select-none w-(--cell-size)", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] select-none text-muted-foreground", + defaultClassNames.week_number + ), + day: cn( + "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", + defaultClassNames.day + ), + range_start: cn( + "rounded-l-md bg-accent", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + ); + }, + cell: ({ row }) => format(row.original.date, "PP"), + filterFn: "dateBetween", + }, + { + accessorKey: "credits", + header: "Credits", + cell: ({ row }) => row.original.credits.toLocaleString(), + }, + { + accessorKey: "usdcPaid", + header: ({ column }) => { + const sortDirection = column.getIsSorted(); + return ( + + ); + }, + cell: ({ row }) => `$${row.original.usdcPaid.toFixed(2)}`, + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const status = row.original.status.toUpperCase(); + + // Define status-specific styling for better visibility in both themes + const getStatusStyle = () => { + switch (status) { + case "COMPLETE": + case "COMPLETED": + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100 border-green-300 dark:border-green-700"; + case "CONFIRMED": + return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100 border-blue-300 dark:border-blue-700"; + case "PENDING": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100 border-yellow-300 dark:border-yellow-700"; + case "FAILED": + return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100 border-red-300 dark:border-red-700"; + default: + return "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100 border-gray-300 dark:border-gray-700"; + } + }; + + return ( + + {status} + + ); + }, + }, + { + accessorKey: "network", + header: "Network", + cell: ({ row }) => getNetworkName(row.original.network), + }, + { + accessorKey: "txHash", + header: "Transaction", + cell: ({ row }) => { + const explorerUrl = getExplorerUrl(row.original.network, row.original.txHash); + return ( + e.stopPropagation()} + className="flex items-center gap-2 text-blue-500 hover:underline" + > + {row.original.txHash.slice(0, 6)}...{row.original.txHash.slice(-4)} + + + ); + }, + }, +]; diff --git a/components/user-transactions-table/table.tsx b/components/user-transactions-table/table.tsx new file mode 100644 index 0000000..66267d6 --- /dev/null +++ b/components/user-transactions-table/table.tsx @@ -0,0 +1,140 @@ +/** + * 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 + */ + +"use client"; + +import { useRouter } from "next/navigation"; +import { + ColumnDef, + flexRender, + Table as TanstackTable, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { DataTableToolbar } from "@/components/user-transactions-table/toolbar"; +import { PurchaseTransaction } from "@/lib/mock-data"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + table: TanstackTable; +} + +export function DataTable({ + columns, + table, +}: DataTableProps) { + const router = useRouter(); + + const handleRowClick = (row: TData) => { + // We cast the row data to our specific type to access txHash + const transaction = row as PurchaseTransaction; + if (transaction.txHash) { + router.push(`/dashboard/${transaction.txHash}`); + } + }; + + return ( +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + handleRowClick(row.original)} + className="cursor-pointer" + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/components/user-transactions-table/toolbar.tsx b/components/user-transactions-table/toolbar.tsx new file mode 100644 index 0000000..674d456 --- /dev/null +++ b/components/user-transactions-table/toolbar.tsx @@ -0,0 +1,136 @@ +/** + * 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 + */ + +"use client"; + +import { useMemo } from "react"; +import { Table } from "@tanstack/react-table"; +import { DateRange } from "react-day-picker"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { CalendarIcon, XIcon } from "lucide-react"; +import { format } from "date-fns"; + +interface DataTableToolbarProps { + table: Table; +} + +export function DataTableToolbar({ table }: DataTableToolbarProps) { + const isFiltered = table.getState().columnFilters.length > 0; + + // read from the table state. the value is an array: [Date | undefined, Date | undefined] + const dateFilterValue = table.getColumn("date")?.getFilterValue() as [Date | undefined, Date | undefined] | undefined; + + // translate the array into a DateRange object for the Calendar component. + const selectedDateRange: DateRange | undefined = useMemo(() => { + if (!dateFilterValue) return undefined; + const [from, to] = dateFilterValue; + return { from, to }; + }, [dateFilterValue]); + + return ( +
+
+ {/* shadcn date range Picker */} + + + + + + { + table.getColumn("date")?.setFilterValue( + newDateRange ? [newDateRange.from, newDateRange.to] : undefined + ); + }} + numberOfMonths={2} + /> + + + + {/* Status Select */} + + + {/* Network Select */} + + + {isFiltered && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/components/wagmi-provider.tsx b/components/wagmi-provider.tsx deleted file mode 100644 index 6f486a4..0000000 --- a/components/wagmi-provider.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Copyright 2026 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 - */ - -"use client"; -import { WagmiConfig, createConfig } from "wagmi"; -import { http } from '@wagmi/core' -import { - mainnet, - polygon, - arbitrum, - arbitrumSepolia, - base, - baseSepolia, - optimism, - optimismSepolia, - polygonAmoy, - sepolia, -} from "wagmi/chains"; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useState } from "react"; - -const chains = [ - mainnet, - polygon, - arbitrum, - arbitrumSepolia, - base, - baseSepolia, - optimism, - optimismSepolia, - polygonAmoy, - sepolia, -] as const; - -const transports = { - [mainnet.id]: http(), - [polygon.id]: http(), - [arbitrum.id]: http(), - [arbitrumSepolia.id]: http(), - [base.id]: http(), - [baseSepolia.id]: http(), - [optimism.id]: http(), - [optimismSepolia.id]: http(), - [polygonAmoy.id]: http(), - [sepolia.id]: http(), -}; - -const wagmiConfig = createConfig({ - chains: chains, // Add chains to wagmiConfig - transports: transports, // Add transports to wagmiConfig -}); - -export function WagmiProvider({ children }: { children: React.ReactNode }) { - const [queryClient] = useState(() => new QueryClient()); - return ( - - - {children} - - - ); -} diff --git a/components/wallet-dashboard.tsx b/components/wallet-dashboard.tsx deleted file mode 100644 index 2dff6ff..0000000 --- a/components/wallet-dashboard.tsx +++ /dev/null @@ -1,527 +0,0 @@ -/** - * Copyright 2026 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 - */ - -"use client"; - -import { Button } from "@/components/ui/button"; -import { useAccount, useConnections } from "wagmi"; -import { ConnectWallet } from "@/components/connect-wallet"; -import { - Card, - CardContent, - CardHeader, - CardTitle, - CardDescription, -} from "@/components/ui/card"; -import { useEffect, useMemo, useState } from "react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Skeleton } from "@/components/ui/skeleton"; -import { createClient } from "@/lib/supabase/client"; -import { ChainBalance } from "@/lib/chain-config"; -import { AlertCircleIcon } from "lucide-react"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Separator } from "@/components/ui/separator" -import { TransactionHistory } from "@/components/transaction-history"; -import { toast } from "sonner"; - -type SupportedChain = "arcTestnet" | "baseSepolia" | "avalancheFuji"; - -export function WalletDashboard() { - const [isMounted, setIsMounted] = useState(false); - - const [hasCircleWallet, setHasCircleWallet] = useState(false); - const [circleWalletAddresses, setCircleWalletAddresses] = useState([]); - const [isCheckingCircleWallet, setIsCheckingCircleWallet] = useState(true); - - useEffect(() => { - setIsMounted(true); - }, []); - - useEffect(() => { - const checkCircleWallet = async () => { - const supabase = createClient(); - const { data: { user } } = await supabase.auth.getUser(); - if (user) { - const { data, error } = await supabase - .from("wallets") - .select("wallet_address") - .eq("user_id", user.id); - - if (data && data.length > 0 && !error) { - setHasCircleWallet(true); - setCircleWalletAddresses(data.map(w => w.wallet_address)); - } - } - setIsCheckingCircleWallet(false); - }; - checkCircleWallet(); - }, []); - - const { isConnected, isConnecting } = useAccount(); - const connections = useConnections(); - - // Create a unified list of all wallet addresses (from wagmi and Circle) - const allWalletAddresses = useMemo(() => { - const wagmiAddresses = connections.map((conn) => conn.accounts).flat(); - return Array.from(new Set([...wagmiAddresses, ...circleWalletAddresses])); - }, [connections, circleWalletAddresses]); - - // Helper function to format address suffix - const formatAddressSuffix = (address: string) => { - if (!address || address.length < 10) return ""; - return `(${address.slice(0, 5)}...${address.slice(-4)})`; - }; - const [totalBalance, setTotalBalance] = useState(null); - const [gatewayBalance, setGatewayBalance] = useState(0); - const [walletBalance, setWalletBalance] = useState(0); - const [chainBalances, setChainBalances] = useState([]); - const [balanceLoading, setBalanceLoading] = useState(true); - - // Deposit state - const [depositAmount, setDepositAmount] = useState(""); - const [depositLoading, setDepositLoading] = useState(false); - - // Transfer state - const [sourceChain] = useState("arcTestnet"); - const [destinationChain, setDestinationChain] = useState("baseSepolia"); - const [transferAmount, setTransferAmount] = useState(""); - const [recipientAddress, setRecipientAddress] = useState(""); - const [transferLoading, setTransferLoading] = useState(false); - - // Refetchable balance function - const fetchBalances = async (showLoadingState = true) => { - // Wait until the initial wallet check is complete - if (isCheckingCircleWallet) return; - - if (allWalletAddresses.length === 0) { - setBalanceLoading(false); - setTotalBalance(null); - setGatewayBalance(0); - setWalletBalance(0); - setChainBalances([]); - return; - } - - if (showLoadingState) { - setBalanceLoading(true); - } - - try { - const response = await fetch("/api/gateway/balance", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ addresses: allWalletAddresses }), - }); - - if (!response.ok) { - throw new Error(`Error: ${response.statusText}`); - } - - const data = await response.json(); - - let totalGateway = 0; - let totalWallet = 0; - const allGatewayBalances: ChainBalance[] = []; - const allChainBalances: ChainBalance[] = []; - - data.balances.forEach((b: any) => { - totalGateway += b.gatewayTotal || 0; - totalWallet += b.walletTotal || 0; - - if (b.gatewayBalances) { - allGatewayBalances.push(...b.gatewayBalances); - } - if (b.chainBalances) { - allChainBalances.push(...b.chainBalances); - } - }); - - setTotalBalance(data.totalUnified); - setGatewayBalance(totalGateway); - setWalletBalance(totalWallet); - setChainBalances(allChainBalances); - } catch (err: any) { - toast.error("Balance Update Failed", { - description: `Failed to fetch balances: ${err.message}`, - }); - } finally { - if (showLoadingState) { - setBalanceLoading(false); - } - } - }; - - // Fetch balances whenever the unified list of addresses changes - useEffect(() => { - fetchBalances(); - }, [allWalletAddresses, isCheckingCircleWallet]); - - const handleDeposit = async () => { - if (!depositAmount) { - toast.error("Invalid Amount", { - description: "Please provide an amount to deposit.", - }); - return; - } - if (!hasCircleWallet) { - toast.error("Missing Credentials", { - description: "Please provide a private key for your connected wallet.", - }); - return; - } - - setDepositLoading(true); - try { - const payload: any = { - chain: "arcTestnet", - amount: depositAmount, - }; - - const response = await fetch("/api/gateway/deposit", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Deposit failed"); - } - const data = await response.json(); - - toast.success("Deposit Successful", { - description: `Transaction Hash: ${data.txHash}`, - }); - - setDepositAmount(""); - - // Instantly refresh balance - await fetchBalances(false); - } catch (err: any) { - toast.error("Deposit Failed", { - description: err.message || "An error occurred during deposit", - }); - } finally { - setDepositLoading(false); - } - }; - - const handleTransfer = async () => { - if (!transferAmount) { - toast.error("Invalid Amount", { - description: "Please provide an amount to transfer.", - }); - return; - } - if (!hasCircleWallet) { - toast.error("Missing Credentials", { - description: "Please provide a private key for your connected wallet.", - }); - return; - } - if (sourceChain === destinationChain) { - toast.error("Invalid Chain Selection", { - description: "Source and destination chains must be different.", - }); - return; - } - - setTransferLoading(true); - try { - const payload: any = { - sourceChain, - destinationChain, - amount: transferAmount, - recipientAddress: recipientAddress || undefined, - }; - - const response = await fetch("/api/gateway/transfer", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - const error = await response.json(); - const errorMessage = error.error || "Transfer failed"; - - if (errorMessage.includes("insufficient funds for transfer") || - errorMessage.includes("exceeds the balance of the account")) { - const chainNames: Record = { - arcTestnet: "Arc Testnet", - avalancheFuji: "Avalanche Fuji", - baseSepolia: "Base Sepolia", - }; - const nativeTokens: Record = { - arcTestnet: "ARC", - avalancheFuji: "AVAX", - baseSepolia: "ETH", - }; - const chainName = chainNames[destinationChain]; - const nativeToken = nativeTokens[destinationChain]; - throw new Error( - `Insufficient gas funds: You need ${nativeToken} on ${chainName} to pay for the minting transaction.` - ); - } else if (errorMessage.includes("Insufficient balance") || errorMessage.includes("insufficient balance")) { - throw new Error("Insufficient Gateway balance: You don't have enough USDC in your Gateway balance for this transfer."); - } else if (errorMessage.includes("Invalid address") || errorMessage.includes("invalid recipient")) { - throw new Error("Invalid address: Please check the recipient address and try again."); - } else if (errorMessage.includes("Gateway API error")) { - const apiErrorMatch = errorMessage.match(/Gateway API error: \d+ - (.+)/); - if (apiErrorMatch) { - throw new Error(`Transfer failed: ${apiErrorMatch[1]}`); - } - throw new Error(errorMessage); - } else { - throw new Error(errorMessage); - } - } - const data = await response.json(); - - toast.success("Transfer Successful", { - description: `Mint Transaction Hash: ${data.mintTxHash}`, - }); - - setTransferAmount(""); - setRecipientAddress(""); - - // Instantly refresh balance - await fetchBalances(false); - } catch (err: any) { - toast.error("Transfer Failed", { - description: err?.message || "An unexpected error occurred. Please try again.", - }); - } finally { - setTransferLoading(false); - } - }; - - // This prevents the server (disconnected) and client (connected) mismatch - const userHasWallet = (isMounted && isConnected) || hasCircleWallet; - - return ( - <> - {/* Disconnected State View */} -
- - - Wallet Setup - - Connect your wallet to begin. - - - - - - -
- - {/* Connected State View */} -
-
- {/* Top Row: Wallet and Balances */} -
- - - Wallet Setup - - Manage your connected wallets. - {!hasCircleWallet && " Provide a private key to sign transactions."} - - - - - - - - - - USDC Balance Overview - - Your combined balance across all wallets and chains. - - - - {balanceLoading || isConnecting ? ( -
- - - -
- ) : totalBalance !== null ? ( -
-
-

- Arc Gateway Balance -

-

- {gatewayBalance.toFixed(2)} USDC -

-
- -
-

- Wallet Balance -

-

- {walletBalance.toFixed(2)} USDC -

-
- {chainBalances.filter(cb => cb.balance > 0).length > 0 ? ( -
    - {chainBalances.filter(cb => cb.balance > 0).map((cb, idx) => ( -
  • - - {cb.chain} {formatAddressSuffix(cb.address)} - - - {cb.balance.toFixed(2)} - -
  • - ))} -
- ) : ( -

- No wallet balance on any chain -

- )} -
- ) : null} -
-
-
- - {/* Actions Card with Tabs */} - - - Actions - - Manage your unified balance by depositing or transferring funds. - - - - - - Deposit - Transfer - - - -
-
- - setDepositAmount(e.target.value)} - disabled={depositLoading} - /> -
- -
-
- - -
-
- - -
- - - Gas Fees Required - - You need native tokens on the destination chain to pay for gas fees when minting. - - -
- - setTransferAmount(e.target.value)} - disabled={transferLoading} - /> -
-
- - setRecipientAddress(e.target.value)} - disabled={transferLoading} - /> -
- -
-
-
-
-
- - {/* --- Transaction History Table --- */} - -
-
- - ); -} diff --git a/components/wallet/connect-wallet-button.tsx b/components/wallet/connect-wallet-button.tsx new file mode 100644 index 0000000..c284da1 --- /dev/null +++ b/components/wallet/connect-wallet-button.tsx @@ -0,0 +1,96 @@ +/** + * 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 + */ + +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { useAccount, useConnect, useDisconnect } from "wagmi"; +import { Button } from "@/components/ui/button"; + +export function ConnectWalletButton() { + const { address, isConnected } = useAccount(); + const { connect, connectors, status: connectStatus } = useConnect(); + const { disconnect } = useDisconnect(); + + const [isClient, setIsClient] = useState(false); + useEffect(() => { + setIsClient(true); + }, []); + + const walletConnector = useMemo( + () => connectors.find((c) => c.id === "injected"), + [connectors] + ); + + function shortAddress(addr: string) { + return addr.slice(0, 6) + "..." + addr.slice(-4); + } + + // On the server, and for the initial client render, show a neutral placeholder. + if (!isClient) { + return ( + + ); + } + + // From this point on, we are on the client and can safely check window.ethereum + if (!(window as { ethereum?: unknown }).ethereum) { + return ( + + ); + } + + if (!isConnected) { + return ( + + ); + } + + return ( +
+ + +
+ ); +} diff --git a/components/wallet/network-indicator.tsx b/components/wallet/network-indicator.tsx new file mode 100644 index 0000000..f3b85d3 --- /dev/null +++ b/components/wallet/network-indicator.tsx @@ -0,0 +1,82 @@ +/** + * 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 + */ + +"use client"; + +import type { Chain } from "viem"; +import { useNetworkSupport } from "@/lib/wagmi/useNetworkSupport"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +const arcTestnet = { + id: 5042002, + name: "Arc Testnet", + nativeCurrency: { name: "USDC", symbol: "USDC", decimals: 18 }, + rpcUrls: { + default: { http: ["https://rpc.testnet.arc.network"] }, + }, + blockExplorers: { + default: { name: "Arcscan", url: "https://testnet.arcscan.app/" }, + }, + contracts: { + multicall3: { + address: "0xca11bde05977b3631167028862be2a173976ca11", + }, + }, +} as const satisfies Chain + +export function NetworkIndicator() { + const { + isConnected, + isSupported, + currentChainName, + currentChainId, + trySwitch, + } = useNetworkSupport(); + + if (!isConnected) return null; + + return ( + + + + + + Testnets + + trySwitch(arcTestnet.id)} + > + {arcTestnet.name} + + + + + ); +} diff --git a/components/wallet/purchase-credits-card.tsx b/components/wallet/purchase-credits-card.tsx new file mode 100644 index 0000000..4b565ef --- /dev/null +++ b/components/wallet/purchase-credits-card.tsx @@ -0,0 +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 + */ + +"use client"; + +import { useState, useMemo, useEffect } from "react"; +import { useAccount, useChainId, useWriteContract } from "wagmi"; +import { BaseError, erc20Abi } from "viem"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useUsdcBalance } from "@/lib/wagmi/useUsdcBalance"; +import { toast } from "sonner"; +import Image from "next/image"; +import Link from "next/link"; +import { TransactionConfirmationModal } from "@/components/wallet/transaction-confirmation-modal"; + +const USDC_PER_CREDIT = 1; +const presetUsdcAmounts = [10, 25, 50, 100]; + +export function PurchaseCreditsCard() { + const { address, isConnected } = useAccount(); + const chainId = useChainId(); + const { + usdcAddress, + balance, + hasBalance, + isLoading: isBalanceLoading, + } = useUsdcBalance(); + const { writeContractAsync } = useWriteContract(); + const [creditsToPurchase, setCreditsToPurchase] = useState(10); + const [isSubmitting, setIsSubmitting] = useState(false); + const [currentTransaction, setCurrentTransaction] = useState<{ + id: string; + credits: number; + usdcAmount: number; + txHash: string; + chainId: number; + status: "pending" | "confirmed" | "failed"; + createdAt: string; + fee?: number; + } | null>(null); + const [showConfirmation, setShowConfirmation] = useState(false); + // State to hold the fetched destination address and its loading status + const [destination, setDestination] = useState<`0x${string}` | undefined>(); + const [isLoadingDestination, setIsLoadingDestination] = useState(true); + + // Effect to fetch the destination address from our new API endpoint + useEffect(() => { + async function fetchDestinationWallet() { + try { + setIsLoadingDestination(true); + const response = await fetch('/api/destination-wallet'); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || "Failed to fetch destination wallet"); + } + if (data.address) { + setDestination(data.address); + } else { + throw new Error("API did not return a valid address."); + } + } catch (error) { + console.error(error); + toast.error("Configuration Error", { + description: error instanceof Error ? error.message : "Could not load the destination address.", + }); + } finally { + setIsLoadingDestination(false); + } + } + + fetchDestinationWallet(); + }, []); + + const requiredUsdc = creditsToPurchase * USDC_PER_CREDIT; + const requiredUsdcMicro = useMemo(() => { + // Convert to 6‑decimal integer (avoid FP drift) + const micro = Math.round(requiredUsdc * 1_000_000); + return BigInt(micro); + }, [requiredUsdc]); + + const hasSufficientBalance = + hasBalance && balance !== null + ? balance >= requiredUsdcMicro + : false; + + const buttonDisabled = + !isConnected || + !hasSufficientBalance || + creditsToPurchase <= 0 || + !destination || + !usdcAddress || + isSubmitting || + isBalanceLoading; + + async function handlePurchase() { + if (!isConnected || !address) { + toast.error("Not connected", { + description: "Connect your wallet first.", + }); + return; + } + if (!destination) { + toast.error("Configuration error", { + description: "Destination address missing.", + }); + return; + } + if (!usdcAddress) { + toast.error("Unsupported network", { + description: "USDC not supported on current chain.", + }); + return; + } + if (!hasSufficientBalance) { + toast.error("Insufficient balance", { + description: "Your USDC balance is too low for this purchase.", + }); + return; + } + + setIsSubmitting(true); + try { + // Prompt wallet (MetaMask/etc) for ERC20 transfer + const txHash = await writeContractAsync({ + address: usdcAddress, + abi: erc20Abi, + functionName: "transfer", + args: [destination, requiredUsdcMicro], + }); + + toast.success("Transaction submitted", { + description: `Hash: ${txHash.slice(0, 10)}...`, + }); + + // Persist (fire-and-forget with basic handling) + 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 + }), + }); + + if (!res.ok) { + const j = await res.json().catch(() => ({})); + toast.error("Recording failed", { + description: j.error || "Could not record transaction.", + }); + } else { + const responseData = await res.json(); + + // 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, + txHash, + chainId, + status: "pending" as const, + createdAt: new Date().toISOString(), + fee: 0, // Network fees are handled separately + }; + + setCurrentTransaction(transaction); + setShowConfirmation(true); + + toast.success("Transaction recorded", { + description: "Monitoring for confirmation...", + }); + } + } catch (err) { + const message = + err instanceof BaseError + ? err.shortMessage + : "Transaction failed unexpectedly."; + toast.error("Transaction error", { + description: message, + }); + } finally { + setIsSubmitting(false); + } + } + + const handleRetry = () => { + setShowConfirmation(false); + setCurrentTransaction(null); + // The user can click the purchase button again to retry + }; + + const handleCloseConfirmation = () => { + setShowConfirmation(false); + setCurrentTransaction(null); + }; + + return ( + <> + + + Purchase Credits + + Top up your account balance using USDC. + + + +
+ + + setCreditsToPurchase(Math.max(0, Number(e.target.value))) + } + min="1" + disabled={!isConnected || isSubmitting} + /> +
+ +
+ {presetUsdcAmounts.map((amount) => { + // The logic here updates automatically with the new conversion rate. + // e.g., $10 button now sets credits to 10. + const credits = amount / USDC_PER_CREDIT; + const isActive = creditsToPurchase === credits; + return ( + + ); + })} +
+ +
+
+ You will pay{" "} + + {requiredUsdc.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{" "} + USDC + +
+ {isLoadingDestination && ( +
+ Destination address not configured. +
+ )} + {!hasSufficientBalance && isConnected && !isBalanceLoading && ( +
+ Insufficient USDC balance. +
+ )} +
+
+ + +
+

+ Powered by{" "} + + Circle + +

+
+
+
+ + + + ); +} \ No newline at end of file diff --git a/components/wallet/transaction-confirmation-modal.tsx b/components/wallet/transaction-confirmation-modal.tsx new file mode 100644 index 0000000..aff5340 --- /dev/null +++ b/components/wallet/transaction-confirmation-modal.tsx @@ -0,0 +1,377 @@ +/** + * 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 + */ + +"use client"; + +import type { RealtimeChannel, SupabaseClient } from "@supabase/supabase-js"; +import { useState, useEffect } from "react"; +import { useWaitForTransactionReceipt } from "wagmi"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { toast } from "sonner"; +import { CheckCircle, Clock, XCircle, ExternalLink, Copy } from "lucide-react"; +import { format } from "date-fns"; +import Link from "next/link"; + +interface TransactionData { + id: string; + amount_usdc?: number; + usdcAmount: number; + tx_hash?: string; + txHash: string; + chainId: number; + chain?: string; + status: "pending" | "confirmed" | "failed" | "complete"; + created_at?: string; + createdAt: string; + fee_usdc?: number; + fee?: number; + networkName?: string; +} + +interface TransactionConfirmationModalProps { + isOpen: boolean; + onClose: () => void; + transaction: TransactionData | null; + onRetry?: () => void; +} + +const getNetworkName = (chainId: number): string => { + const networks: Record = { + 1: "Ethereum", + 137: "Polygon", + 8453: "Base", + 42161: "Arbitrum", + 10: "Optimism", + }; + + return networks[chainId] || `Chain ${chainId}`; +}; + +const DetailRow = ({ label, value }: { label: string; value: React.ReactNode }) => ( +
+ {label} + {value} +
+); + +export function TransactionConfirmationModal({ + isOpen, + onClose, + transaction, + onRetry, +}: TransactionConfirmationModalProps) { + const [lastUpdated, setLastUpdated] = useState(null); + const [realtimeTx, setRealtimeTx] = useState(transaction); + + // Monitor transaction receipt from MetaMask/wallet + const { data: receipt, isSuccess: isReceiptConfirmed } = useWaitForTransactionReceipt({ + hash: transaction?.txHash as `0x${string}` | undefined, + chainId: transaction?.chainId, + confirmations: 1, + query: { + enabled: !!transaction && transaction.status === "pending", + }, + }); + + // Update status to 'complete' when MetaMask confirms the transaction + useEffect(() => { + if (!transaction || !isReceiptConfirmed || !receipt) return; + + const updateTransactionStatus = async () => { + try { + const response = await fetch(`/api/transactions/${transaction.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + status: "complete", + txHash: transaction.txHash, + blockNumber: Number(receipt.blockNumber), + blockHash: receipt.blockHash, + }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.transaction) { + setRealtimeTx(prev => ({ + ...prev!, + status: "complete", + })); + setLastUpdated(new Date()); + toast.success("Transaction confirmed on-chain!", { + description: "Waiting for Circle to process...", + }); + } + } + } catch (error) { + console.error("Failed to update transaction status:", error); + // Don't show error toast - Circle webhook will still update it + } + }; + + updateTransactionStatus(); + }, [transaction, isReceiptConfirmed, receipt]); + + useEffect(() => { + if (!transaction || !isOpen) return; + + // Dynamically import Supabase client to avoid SSR issues + let channel: RealtimeChannel; + let supabase: SupabaseClient; + let isMounted = true; + + (async () => { + const { createClient } = await import("@/lib/supabase/client"); + supabase = createClient(); + + channel = supabase + .channel("user_transaction_changes") + .on( + "postgres_changes", + { event: "UPDATE", schema: "public", table: "transactions", filter: `id=eq.${transaction.id}` }, + payload => { + if (!isMounted) return; + + const updatedTx = payload.new as TransactionData; + + if (updatedTx.status !== transaction.status) { + setRealtimeTx(updatedTx); + setLastUpdated(new Date()); + + if (updatedTx.status === "confirmed" || updatedTx.status === "complete") { + toast.success("Transaction confirmed!", { + description: `${updatedTx.amount_usdc} credits added to your account.`, + }); + } else if (updatedTx.status === "failed") { + toast.error("Transaction failed", { + description: "Your transaction was not successful.", + }); + } + + window.dispatchEvent(new CustomEvent('transaction-updated', { + detail: updatedTx + })); + } + } + ) + .subscribe(); + })(); + + return () => { + isMounted = false; + if (supabase && channel) supabase.removeChannel(channel); + }; + }, [transaction, isOpen]); + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success("Copied to clipboard"); + } catch { + toast.error("Failed to copy"); + } + }; + + const tx = realtimeTx || transaction; + + if (!tx) return null; + + const statusConfig = { + pending: { + icon: Clock, + color: "text-yellow-600", + bgColor: "bg-yellow-50", + badgeVariant: "secondary" as const, + title: "Transaction Pending", + description: "Your transaction is being processed on the blockchain...", + }, + completed: { + icon: CheckCircle, + color: "text-blue-600", + bgColor: "bg-blue-50", + badgeVariant: "secondary" as const, + title: "Transaction Confirmed!", + description: "Your transaction is confirmed on-chain. Waiting for Circle to process...", + }, + confirmed: { + icon: CheckCircle, + color: "text-green-600", + bgColor: "bg-green-50", + badgeVariant: "default" as const, + title: "Payment Confirmed!", + description: "Your credits have been successfully added to your account.", + }, + failed: { + icon: XCircle, + color: "text-red-600", + bgColor: "bg-red-50", + badgeVariant: "destructive" as const, + title: "Transaction Failed", + description: "Your transaction was unsuccessful. Please try again or contact support.", + }, + complete: { + icon: CheckCircle, + color: "text-green-600", + bgColor: "bg-green-50", + badgeVariant: "default" as const, + title: "Transaction Complete!", + description: "Your credits have been successfully added to your account.", + }, + }; + + const config = statusConfig[tx.status]; + const StatusIcon = config.icon; + const networkName = tx.networkName || getNetworkName(tx.chainId || Number(tx.chain)); + + return ( + + + +
+ +
+ {config.title} + + {config.description} + +
+ + + + + + {tx.status.charAt(0).toUpperCase() + tx.status.slice(1)} + +
+ } + /> + + + + + + + + {typeof tx.fee_usdc === "number" && tx.fee_usdc > 0 && ( + + )} + + + + + + + + {(() => { + const hash = tx.tx_hash || tx.txHash; + if (!hash) return "N/A"; + return `${hash.slice(0, 10)}...${hash.slice(-8)}`; + })()} + + +
+ } + /> + + { + const timestamp = tx.created_at || tx.createdAt; + if (!timestamp) return "N/A"; + try { + return format(new Date(timestamp), "PPpp"); + } catch { + return "Invalid date"; + } + })()} + /> + + {lastUpdated && ( + + )} + + + +
+ + + {tx.status === "failed" && onRetry && ( + + )} + + {(tx.status === "confirmed" || tx.status === "complete") && ( + + )} + + +
+ + + ); +} diff --git a/components/wallet/unsupported-network-notice.tsx b/components/wallet/unsupported-network-notice.tsx new file mode 100644 index 0000000..00e22ca --- /dev/null +++ b/components/wallet/unsupported-network-notice.tsx @@ -0,0 +1,70 @@ +/** + * 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 + */ + +"use client"; + +import { AlertTriangle } from "lucide-react"; +import { useNetworkSupport } from "@/lib/wagmi/useNetworkSupport"; +import { Button } from "@/components/ui/button"; +import { useState } from "react"; + +export function UnsupportedNetworkNotice() { + const { + isConnected, + isSupported, + unsupportedReason, + canSwitch, + isSwitching, + trySwitch, + supportedChainIds, + } = useNetworkSupport(); + const [dismissed, setDismissed] = useState(false); + + if (!isConnected || isSupported || dismissed) return null; + + return ( +
+ +
+
Unsupported Network
+
+ {unsupportedReason || + "You are connected to a network that this app does not support."} +
+
+ {canSwitch && ( + + )} + +
+
+
+ ); +} diff --git a/components/wallet/usdc-balance.tsx b/components/wallet/usdc-balance.tsx new file mode 100644 index 0000000..f26e49c --- /dev/null +++ b/components/wallet/usdc-balance.tsx @@ -0,0 +1,89 @@ +/** + * 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 + */ + +"use client"; + +import { useAccount } from "wagmi"; +import { useUsdcBalance } from "@/lib/wagmi/useUsdcBalance"; +import { Button } from "@/components/ui/button"; + +export function UsdcBalance() { + const { isConnected } = useAccount(); + const { + formatted, + isLoading: usdcLoading, + hasBalance, + unsupported: usdcUnsupported, + error: usdcError, + } = useUsdcBalance(); + + // If the wallet is not connected, we don't show anything. + if (!isConnected) { + return ( + + Connect wallet to see balance + + ); + } + + if (usdcUnsupported) { + return ( + + ); + } + + if (usdcLoading) { + return ( + + ); + } + + if (usdcError) { + return ( + + ); + } + + if (hasBalance === false) { + return ( + + No USDC balance found. + + ); + } + + if (hasBalance && formatted) { + const display = Number(formatted).toLocaleString(undefined, { + maximumFractionDigits: 2, + }); + return ( + + ); + } + + // Fallback for any unhandled state + return null; +} \ No newline at end of file diff --git a/components/wallet/wallet-provider.tsx b/components/wallet/wallet-provider.tsx new file mode 100644 index 0000000..0dd119c --- /dev/null +++ b/components/wallet/wallet-provider.tsx @@ -0,0 +1,46 @@ +/** + * 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 + */ + +"use client"; + +import { WagmiProvider } from "wagmi"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { wagmiConfig } from "@/lib/wagmi/config"; + +let queryClient: QueryClient | null = null; +function getQueryClient() { + if (!queryClient) { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: false, + }, + }, + }); + } + return queryClient; +} + +export function WalletProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/components/wallet/wallet-status-card.tsx b/components/wallet/wallet-status-card.tsx new file mode 100644 index 0000000..55b6c91 --- /dev/null +++ b/components/wallet/wallet-status-card.tsx @@ -0,0 +1,61 @@ +/** + * 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 + */ + +"use client"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ConnectWalletButton } from "@/components/wallet/connect-wallet-button"; +import { NetworkIndicator } from "@/components/wallet/network-indicator"; +import { UsdcBalance } from "@/components/wallet/usdc-balance"; +import { Separator } from "@/components/ui/separator"; + +export function WalletStatusCard() { + return ( + + + Wallet Status + + Connect your wallet and check your network and USDC balance. + + + +
+
+ Connection + +
+
+ Network + +
+ +
+ USDC Balance + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index ea29349..8d95a2b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. diff --git a/hooks/use-realtime-admin-transactions.ts b/hooks/use-realtime-admin-transactions.ts new file mode 100644 index 0000000..c0fe840 --- /dev/null +++ b/hooks/use-realtime-admin-transactions.ts @@ -0,0 +1,210 @@ +/** + * 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 + */ + +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { createClient } from "@/lib/supabase/client"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; +import { AdminTransaction } from "@/types/admin-transaction"; +import { getAdminWalletAddresses } from "@/lib/actions/admin-wallets"; + +export type { AdminTransaction }; + +export function useRealtimeAdminTransactions(initialData: AdminTransaction[]) { + const [transactions, setTransactions] = useState(initialData); + const [adminWalletAddresses, setAdminWalletAddresses] = useState([]); + const [isLoadingAddresses, setIsLoadingAddresses] = useState(true); + const supabase = useMemo(() => createClient(), []); + const router = useRouter(); + + // Fetch admin wallet addresses for filtering USER transactions using server action + useEffect(() => { + async function fetchAdminWallets() { + console.log("[Realtime] Fetching admin wallet addresses via server action..."); + setIsLoadingAddresses(true); + + try { + const addresses = await getAdminWalletAddresses(); + console.log("[Realtime] Fetched admin wallet addresses:", addresses.length, addresses); + setAdminWalletAddresses(addresses); + } catch (error) { + console.error("[Realtime] Error fetching admin wallets:", error); + } finally { + setIsLoadingAddresses(false); + } + } + fetchAdminWallets(); + }, []); + + // Sync initialData to state when it changes (from router.refresh()) + useEffect(() => { + console.log("[Realtime] Syncing initialData to state"); + setTransactions(initialData); + }, [initialData]); + + // Set up realtime subscription (only re-subscribe if admin wallets or loading state changes) + useEffect(() => { + console.log("[Realtime] Setting up subscription. Admin wallet addresses:", adminWalletAddresses.length, "Loading:", isLoadingAddresses); + + // Don't set up subscription until admin wallet addresses are loaded + // This prevents premature subscription attempts that will timeout + if (isLoadingAddresses) { + console.log("[Realtime] Waiting for admin wallet addresses to load before subscribing..."); + return; + } + + console.log("[Realtime] Admin wallet addresses loaded. Proceeding with subscription setup."); + + let channel: ReturnType | null = null; + let cancelled = false; + + // Wait for authentication before subscribing + supabase.auth.getUser().then(({ data: { user } }) => { + if (!user || cancelled) { + console.log("[Realtime] No authenticated user, skipping subscription"); + return; + } + + console.log("[Realtime] Creating subscription channel for authenticated user..."); + console.log("[Realtime] Admin wallet addresses available:", adminWalletAddresses.length); + + channel = supabase + .channel("transactions_changes") + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "transactions", + // Listen to all transactions - we'll filter client-side + }, + (payload) => { + console.log("[Realtime] Update received:", payload); + + const transaction = payload.new as AdminTransaction; + + // Filter: include if it's NOT a USER transaction, OR if it's a USER transaction to an admin wallet + const isAdminTransaction = transaction.transaction_type !== "USER"; + const isUserToAdminWallet = + transaction.transaction_type === "USER" && + adminWalletAddresses.length > 0 && + adminWalletAddresses.includes(transaction.destination_address); + + console.log("[Realtime] Filtering:", { + transactionType: transaction.transaction_type, + isAdminTransaction, + isUserToAdminWallet, + adminWalletsLoaded: adminWalletAddresses.length > 0, + willProcess: isAdminTransaction || isUserToAdminWallet + }); + + if (!isAdminTransaction && !isUserToAdminWallet) { + console.log("[Realtime] Ignoring USER transaction not directed to admin wallet"); + return; + } + + if (payload.eventType === "INSERT") { + const newTransaction = payload.new as AdminTransaction; + + // 1. Immediately add the new row with "N/A" for instant UI feedback. + setTransactions((current) => [ + { ...newTransaction, source_wallet: { label: "Loading..." } }, + ...current, + ]); + + // 2. Trigger a soft refresh of the page's data. + // Next.js will re-fetch the server component data in the background + // and seamlessly update the table with the complete, joined data. + router.refresh(); + + toast.info("New transaction initiated.", { + description: `ID: ${newTransaction.circle_transaction_id?.slice(0, 15) || newTransaction.id.slice(0, 15)}...`, + }); + } + + if (payload.eventType === "UPDATE") { + const oldTx = payload.old as Partial; + const newTx = payload.new as AdminTransaction; + + console.log("[Realtime] UPDATE event:", { + oldStatus: oldTx?.status, + newStatus: newTx.status, + transactionId: newTx.id, + }); + + // Update the transaction in state with all new fields (not just status) + setTransactions((current) => + current.map((tx) => + tx.id === newTx.id + ? { ...tx, ...newTx } + : tx + ) + ); + + // Show toast notifications only if status actually changed + const statusChanged = !oldTx?.status || oldTx.status !== newTx.status; + if (statusChanged) { + if (newTx.status === "confirmed") { + toast.success("Transfer Confirmed by Circle", { + description: `Transaction ${newTx.circle_transaction_id?.slice(0, 15) || newTx.id.slice(0, 15)}... verified by Circle.`, + }); + } else if (newTx.status === "complete") { + toast.success("Transfer Complete!", { + description: `Funds have been deposited for ${newTx.circle_transaction_id?.slice(0, 15) || newTx.id.slice(0, 15)}...`, + }); + } else if (newTx.status === "failed") { + toast.error("Transaction Failed", { + description: `Transaction ${newTx.circle_transaction_id?.slice(0, 15) || newTx.id.slice(0, 15)}... has failed.`, + }); + } + } + } + } + ) + .subscribe((status, err) => { + console.log("[Realtime] Subscription status:", status, err ? `Error: ${err}` : ''); + if (status === 'SUBSCRIBED') { + console.log("[Realtime] Successfully subscribed to transactions_changes channel"); + } else if (status === 'CHANNEL_ERROR') { + const errorMsg = err ? err : "Unknown channel error (no error object provided)"; + console.error("[Realtime] Channel error:", errorMsg); + toast.error("Realtime channel error", { + description: typeof errorMsg === "string" ? errorMsg : JSON.stringify(errorMsg), + }); + } else if (status === 'TIMED_OUT') { + console.error("[Realtime] Subscription timed out"); + toast.error("Realtime subscription timed out"); + } + }); + }).catch((error) => { + console.error("[Realtime] Failed to setup realtime subscription:", error); + }); + + return () => { + cancelled = true; + if (channel) { + console.log("[Realtime] Cleaning up subscription channel"); + supabase.removeChannel(channel); + } + }; + }, [supabase, router, adminWalletAddresses, isLoadingAddresses]); + + return transactions; +} diff --git a/lib/actions/admin-wallets.ts b/lib/actions/admin-wallets.ts new file mode 100644 index 0000000..56b7fec --- /dev/null +++ b/lib/actions/admin-wallets.ts @@ -0,0 +1,425 @@ +/** + * 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 + */ + +"use server"; + +import { revalidatePath } from "next/cache"; +import { Database } from "@/types/supabase"; +import { circleDeveloperSdk } from "@/lib/circle/developer-controlled-wallets-client"; +import { convertToSmallestUnit } from "@/lib/utils/convert-to-smallest-unit"; +import { supabaseAdminClient } from "@/lib/supabase/admin-client"; +import { CHAIN_IDS_TO_TOKEN_MESSENGER, CHAIN_IDS_TO_USDC_ADDRESSES, SupportedChainId } from "@/lib/chains"; + +const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + : "http://localhost:3000"; + +const circleApiKey = process.env.CIRCLE_API_KEY; +const circleApiBaseUrl = "https://api.circle.com"; + +type WalletStatus = Database["public"]["Enums"]["admin_wallet_status"]; + +export interface TokenBalance { + token: { + blockchain: string; + name: string; + symbol: string; + decimals: number; + }; + amount: string; +} + +interface AxiosErrorLike { + isAxiosError: true; + response?: { + data?: { + message?: string; + }; + }; +} + +function isAxiosError(error: unknown): error is AxiosErrorLike { + return ( + typeof error === "object" && + error !== null && + (error as AxiosErrorLike).isAxiosError === true + ); +} + +/** + * Creates a new Circle wallet via internal API routes and saves it to the database. + */ +export async function createAdminWallet(formData: FormData) { + const label = formData.get("label") as string; + // Get the blockchain from the form data. + const blockchain = formData.get("blockchain") as string; + + if (!label || label.trim().length < 3) { + return { error: "Label must be at least 3 characters long." }; + } + // Add a check for the blockchain + if (!blockchain) { + return { error: "Blockchain is a required field." }; + } + + try { + const createdWalletSetResponse = await fetch(`${baseUrl}/api/wallet-set`, { + method: "POST", + body: JSON.stringify({ entityName: `admin-wallet-${label}` }), + headers: { "Content-Type": "application/json" }, + }); + if (!createdWalletSetResponse.ok) + throw new Error("Failed to create wallet set."); + const createdWalletSet = await createdWalletSetResponse.json(); + + // Pass the selected blockchain to the /api/wallet endpoint. + const createdWalletResponse = await fetch(`${baseUrl}/api/wallet`, { + method: "POST", + body: JSON.stringify({ + walletSetId: createdWalletSet.id, + blockchain, + }), + headers: { "Content-Type": "application/json" }, + }); + if (!createdWalletResponse.ok) throw new Error("Failed to create wallet."); + const newWallet = await createdWalletResponse.json(); + + const { error: insertError } = await supabaseAdminClient + .from("admin_wallets") + .insert({ + circle_wallet_id: newWallet.id, + label: label.trim(), + address: newWallet.address, + chain: newWallet.blockchain, + }); + + if (insertError) throw new Error(insertError.message); + + revalidatePath("/dashboard"); + return { success: true }; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "An unexpected error occurred."; + console.error("Error creating admin wallet:", message); + return { error: message }; + } +} + +/** + * Updates the status of an existing admin wallet. + */ +export async function updateAdminWalletStatus( + id: string, + status: WalletStatus +) { + try { + const { error } = await supabaseAdminClient + .from("admin_wallets") + .update({ status }) + .eq("id", id); + + if (error) throw new Error(error.message); + + revalidatePath("/dashboard"); + return { success: true }; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "An unexpected error occurred."; + console.error( + `Error updating wallet ${id} to status ${status}:`, + message + ); + return { error: message }; + } +} + +/** + * Fetches the token balances for a specific Circle wallet using a direct API call. + */ +export async function getWalletBalance( + walletId: string +): Promise<{ balances?: TokenBalance[]; error?: string }> { + if (!circleApiKey) { + const message = "Circle API Key is not configured on the server."; + console.error(message); + return { error: message }; + } + + try { + const url = `${circleApiBaseUrl}/v1/w3s/wallets/${walletId}/balances?includeAll=true`; + const options = { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${circleApiKey}`, + }, + }; + + const response = await fetch(url, options); + const responseBody = await response.json(); + + if (!response.ok) { + throw new Error( + responseBody.message || "Failed to fetch balances from Circle API." + ); + } + + const balances = responseBody.data.tokenBalances as TokenBalance[] | undefined; + + return { balances: balances ?? [] }; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "An unexpected error occurred."; + console.error(`Error fetching balance for wallet ${walletId}:`, message); + return { error: message }; + } +} + +/** + * Initiates a transfer from a developer-controlled admin wallet and logs the transaction. + */ +export async function transferFromAdminWallet( + sourceCircleWalletId: string, + destinationAddress: string, + amount: string +) { + try { + // Fetch the source wallet's internal DB ID, chain, and address from our database. + const { data: sourceWallet, error: fetchError } = await supabaseAdminClient + .from("admin_wallets") + .select("id, chain, address") + .eq("circle_wallet_id", sourceCircleWalletId) + .single(); + + if (fetchError || !sourceWallet) { + throw new Error("Source wallet not found in the database."); + } + + // 1. Convert the chain string (e.g., "ETH-SEPOLIA") to its corresponding enum key. + const sourceChainKey = sourceWallet.chain.replace(/-/g, '_'); + const sourceChainId = SupportedChainId[sourceChainKey as keyof typeof SupportedChainId]; + + if (sourceChainId === undefined) { + throw new Error(`Unsupported source chain for transfer: ${sourceWallet.chain}`); + } + + // 2. Look up the correct USDC contract address for the source chain. + const usdcContractAddress = CHAIN_IDS_TO_USDC_ADDRESSES[sourceChainId]; + + if (!usdcContractAddress) { + throw new Error(`Could not find a USDC contract address for chain: ${sourceWallet.chain}`); + } + + // 3. Use the robust `createContractExecutionTransaction` to call the `transfer` function. + const response = await circleDeveloperSdk.createContractExecutionTransaction({ + walletId: sourceCircleWalletId, + contractAddress: usdcContractAddress, // The USDC contract on the source chain + abiFunctionSignature: "transfer(address,uint256)", + abiParameters: [ + destinationAddress, + convertToSmallestUnit(amount).toString(), + ], + fee: { + type: "level", + config: { + feeLevel: "HIGH", + }, + }, + }); + + const transactionData = response.data; + + if (!transactionData?.id) { + throw new Error("Failed to initiate transfer with Circle API."); + } + + // Convert chain name to numeric chain ID + const chainKey = (sourceWallet.chain || "").replace(/-/g, '_'); + const chainId = SupportedChainId[chainKey as keyof typeof SupportedChainId]; + + // Log the new transaction to the unified `transactions` table. + const { error: insertError } = await supabaseAdminClient + .from("transactions") + .insert({ + transaction_type: "ADMIN", + circle_transaction_id: transactionData.id, + source_wallet_id: sourceWallet.id, + destination_address: destinationAddress, + amount_usdc: Number(amount), + asset: "USDC", + chain: chainId ? String(chainId) : sourceWallet.chain || "UNKNOWN", + wallet_id: sourceWallet.address, // Source wallet address, not destination + idempotency_key: `admin:${transactionData.id}`, + status: "pending" + }); + + if (insertError) { + console.error( + "CRITICAL: Failed to log transaction to database:", + insertError.message + ); + } + + revalidatePath("/dashboard"); + return { success: true, transactionId: transactionData.id }; + } catch (error: unknown) { + let message = "An unexpected error occurred."; + + if (isAxiosError(error)) { + message = + error.response?.data?.message || "An unknown Circle API error occurred."; + } else if (error instanceof Error) { + message = error.message; + } + + console.error( + `Error transferring from wallet ${sourceCircleWalletId}:`, + message + ); + return { error: message }; + } +} + +export async function transferFromAdminWalletCCTP( + sourceCircleWalletId: string, + destinationAddress: string, + amount: string +) { + + console.log("[CCTP] Approving USDC transfer from source wallet..."); + + const formattedAmount = convertToSmallestUnit(amount); + + try { + // Fetch the source wallet's internal DB ID, chain, and address from our database. + const { data: sourceWallet, error: fetchError } = await supabaseAdminClient + .from("admin_wallets") + .select("id, chain, address") + .eq("circle_wallet_id", sourceCircleWalletId) + .single(); + + if (fetchError || !sourceWallet) { + throw new Error("Source wallet not found in the database."); + } + + // Convert the dash-separated chain string from the DB to the underscore-separated enum key format. + const chainKey = sourceWallet.chain.replace(/-/g, '_'); + + // Get the numerical chain ID from the string stored in the database. + const sourceChainId = SupportedChainId[chainKey as keyof typeof SupportedChainId]; + + if (sourceChainId === undefined) { + throw new Error(`Unsupported source chain: ${sourceWallet.chain}. Please check the configuration.`); + } + + // Look up the correct contract addresses using the chain ID. + const tokenMessengerAddress = CHAIN_IDS_TO_TOKEN_MESSENGER[sourceChainId]; + const usdcContractAddress = CHAIN_IDS_TO_USDC_ADDRESSES[sourceChainId]; + + if (!tokenMessengerAddress || !usdcContractAddress) { + throw new Error(`Contract addresses for chain ID ${sourceChainId} are not defined.`); + } + + const approvalResponse = await circleDeveloperSdk.createContractExecutionTransaction({ + walletId: sourceCircleWalletId, + abiFunctionSignature: "approve(address,uint256)", + abiParameters: [ + // Use the dynamically looked-up Token Messenger address + tokenMessengerAddress, + formattedAmount.toString() + ], + // Use the dynamically looked-up USDC contract address + contractAddress: usdcContractAddress, + fee: { + type: "level", + config: { + feeLevel: "MEDIUM", + }, + }, + }); + + if (!approvalResponse.data?.id) { + throw new Error("Failed to initiate CCTP transfer with Circle API."); + } + + // Log the new transaction to the unified `transactions` table. + const { error: insertError } = await supabaseAdminClient + .from("transactions") + .insert({ + transaction_type: "CCTP_APPROVAL", + circle_transaction_id: approvalResponse.data.id, + source_wallet_id: sourceWallet.id, // Use the internal DB ID + destination_address: destinationAddress, + amount_usdc: Number(amount), + asset: "USDC", + chain: String(sourceChainId), // Use numeric chain ID + wallet_id: sourceWallet.address, // Source wallet address, not destination + idempotency_key: `admin:${approvalResponse.data.id}`, + status: "pending" + }); + + if (insertError) { + // Log this as a critical error but don't fail the entire operation, + // as the on-chain transaction has already been submitted. + console.error( + "CRITICAL: Failed to log transaction to database:", + insertError.message + ); + } + + revalidatePath("/dashboard"); + return { transactionId: approvalResponse.data.id } + } catch (error) { + let message = "An unexpected error occurred."; + + if (isAxiosError(error)) { + message = + error.response?.data?.message || "An unknown Circle API error occurred."; + } else if (error instanceof Error) { + message = error.message; + } + + console.error( + `Error transferring from wallet ${sourceCircleWalletId}:`, + message + ); + return { error: message }; + } +} + +/** + * Fetches all admin wallet addresses for filtering realtime subscriptions. + * Uses admin client to bypass RLS restrictions. + */ +export async function getAdminWalletAddresses(): Promise { + try { + const { data, error } = await supabaseAdminClient + .from("admin_wallets") + .select("address"); + + if (error) { + console.error("[Server Action] Error fetching admin wallet addresses:", error); + return []; + } + + return data?.map(w => w.address) || []; + } catch (error) { + console.error("[Server Action] Unexpected error fetching admin wallet addresses:", error); + return []; + } +} \ No newline at end of file diff --git a/lib/chain-config.ts b/lib/chain-config.ts deleted file mode 100644 index b5de204..0000000 --- a/lib/chain-config.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Copyright 2026 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 - */ - -export type SupportedChain = - | "arcTestnet" - | "baseSepolia" - | "avalancheFuji" - -export const CHAIN_NAMES: Record = { - arcTestnet: "Arc Testnet", - avalancheFuji: "Avalanche Fuji", - baseSepolia: "Base Sepolia", -}; - -export const NATIVE_TOKENS: Record = { - arcTestnet: "ARC", - avalancheFuji: "AVAX", - baseSepolia: "ETH", -}; - -export const SUPPORTED_CHAINS: Array<{ value: SupportedChain; label: string }> = [ - { value: "arcTestnet", label: "Arc Testnet" }, - { value: "baseSepolia", label: "Base Sepolia" }, - { value: "avalancheFuji", label: "Avalanche Fuji" }, -]; - -export interface ChainBalance { - chain: string; - balance: number; - address: string; -} diff --git a/lib/chains.ts b/lib/chains.ts new file mode 100644 index 0000000..85ead4b --- /dev/null +++ b/lib/chains.ts @@ -0,0 +1,78 @@ +/** + * 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 type { Hex } from "viem"; + +export enum SupportedChainId { + ETH_SEPOLIA = 11155111, + AVAX_FUJI = 43113, + BASE_SEPOLIA = 84532, + ARC_TESTNET = 5042002 +} + +export const DEFAULT_MAX_FEE = 1000n; +export const DEFAULT_FINALITY_THRESHOLD = 2000; + +export const CHAIN_TO_CHAIN_NAME: Record = { + [SupportedChainId.ETH_SEPOLIA]: "Ethereum Sepolia", + [SupportedChainId.AVAX_FUJI]: "Avalanche Fuji", + [SupportedChainId.BASE_SEPOLIA]: "Base Sepolia", + [SupportedChainId.ARC_TESTNET]: "Arc Testnet" +}; + +export const CHAIN_IDS_TO_USDC_ADDRESSES: Record = { + [SupportedChainId.ETH_SEPOLIA]: "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", + [SupportedChainId.AVAX_FUJI]: "0x5425890298aed601595a70AB815c96711a31Bc65", + [SupportedChainId.BASE_SEPOLIA]: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + [SupportedChainId.ARC_TESTNET]: "0x3600000000000000000000000000000000000000" +}; + +export const CHAIN_IDS_TO_TOKEN_MESSENGER: Record = { + [SupportedChainId.ETH_SEPOLIA]: "0x8fe6b999dc680ccfdd5bf7eb0974218be2542daa", + [SupportedChainId.AVAX_FUJI]: "0x8fe6b999dc680ccfdd5bf7eb0974218be2542daa", + [SupportedChainId.BASE_SEPOLIA]: "0x8fe6b999dc680ccfdd5bf7eb0974218be2542daa", + [SupportedChainId.ARC_TESTNET]: "0x8fe6b999dc680ccfdd5bf7eb0974218be2542daa" +}; + +export const CHAIN_IDS_TO_USDC_TOKEN_ID: Record = { + [SupportedChainId.ETH_SEPOLIA]: "b1698135-ebb1-5e40-9c3b-6a1213f4b754", + [SupportedChainId.AVAX_FUJI]: "da27d231-a690-5521-a2f3-1873b4134155", + [SupportedChainId.BASE_SEPOLIA]: "5a64c456-03f7-5238-a77a-a780b0b90263", + [SupportedChainId.ARC_TESTNET]: "15dc2b5d-0994-58b0-bf8c-3a0501148ee8" +}; + +export const CHAIN_IDS_TO_MESSAGE_TRANSMITTER: Record = { + [SupportedChainId.ETH_SEPOLIA]: "0xe737e5cebeeba77efe34d4aa090756590b1ce275", + [SupportedChainId.AVAX_FUJI]: "0xe737e5cebeeba77efe34d4aa090756590b1ce275", + [SupportedChainId.BASE_SEPOLIA]: "0xe737e5cebeeba77efe34d4aa090756590b1ce275", + [SupportedChainId.ARC_TESTNET]: "0xe737e5cebeeba77efe34d4aa090756590b1ce275" +}; + +export const DESTINATION_DOMAINS: Record = { + [SupportedChainId.ETH_SEPOLIA]: 0, + [SupportedChainId.AVAX_FUJI]: 1, + [SupportedChainId.BASE_SEPOLIA]: 6, + [SupportedChainId.ARC_TESTNET]: 26 +}; + +export const SUPPORTED_CHAINS = [ + SupportedChainId.ETH_SEPOLIA, + SupportedChainId.AVAX_FUJI, + SupportedChainId.BASE_SEPOLIA, + SupportedChainId.ARC_TESTNET +]; diff --git a/lib/circle/sdk.ts b/lib/circle/developer-controlled-wallets-client.ts similarity index 93% rename from lib/circle/sdk.ts rename to lib/circle/developer-controlled-wallets-client.ts index fae8bac..e58a4cf 100644 --- a/lib/circle/sdk.ts +++ b/lib/circle/developer-controlled-wallets-client.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. diff --git a/lib/circle/gateway-sdk.ts b/lib/circle/gateway-sdk.ts deleted file mode 100644 index 25bfe4f..0000000 --- a/lib/circle/gateway-sdk.ts +++ /dev/null @@ -1,648 +0,0 @@ -/** - * Copyright 2026 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 { randomBytes } from "crypto"; -import { - http, - maxUint256, - zeroAddress, - pad, - createPublicClient, - erc20Abi, - type Address, - type Hash, - type Chain, -} from "viem"; -import * as chains from "viem/chains"; -import { circleDeveloperSdk } from "@/lib/circle/sdk"; - -export const GATEWAY_WALLET_ADDRESS = "0x0077777d7EBA4688BDeF3E311b846F25870A19B9"; -export const GATEWAY_MINTER_ADDRESS = "0x0022222ABE238Cc2C7Bb1f21003F0a260052475B"; - -const arcRpcKey = process.env.ARC_TESTNET_RPC_KEY || 'c0ca2582063a5bbd5db2f98c139775e982b16919'; - -export const arcTestnet = { - id: 5042002, - name: 'Arc Testnet', - nativeCurrency: { name: 'USD Coin', symbol: 'USDC', decimals: 6 }, - rpcUrls: { - default: { http: [`https://rpc.testnet.arc.network/${arcRpcKey}`] }, - }, - blockExplorers: { - default: { name: 'Explorer', url: 'https://explorer.arc.testnet.circle.com' }, - }, - testnet: true, -} as const satisfies Chain; - -export const USDC_ADDRESSES = { - arcTestnet: "0x3600000000000000000000000000000000000000", - avalancheFuji: "0x5425890298aed601595a70ab815c96711a31bc65", - baseSepolia: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", -} as const; - -export const TOKEN_IDS = { - arcTestnet: "15dc2b5d-0994-58b0-bf8c-3a0501148ee8", - sepolia: "d2177333-b33a-5263-b699-2a6a52722214", -} as const; - -export const DOMAIN_IDS = { - avalancheFuji: 1, - baseSepolia: 6, - arcTestnet: 26, -} as const; - -export type SupportedChain = keyof typeof USDC_ADDRESSES; - -// Mapping for Circle API "blockchain" parameter -export const CIRCLE_CHAIN_NAMES: Record = { - avalancheFuji: "AVAX-FUJI", - baseSepolia: "BASE-SEPOLIA", - arcTestnet: "ARC-TESTNET", -}; - -export const CHAIN_BY_DOMAIN: Record = { - [DOMAIN_IDS.avalancheFuji]: "avalancheFuji", - [DOMAIN_IDS.baseSepolia]: "baseSepolia", - [DOMAIN_IDS.arcTestnet]: "arcTestnet", -} as const; - -function getChainConfig(chain: SupportedChain): Chain { - switch (chain) { - case "arcTestnet": - return arcTestnet; - case "avalancheFuji": - return chains.avalancheFuji; - case "baseSepolia": - return chains.baseSepolia; - default: - throw new Error(`Unsupported chain: ${chain}`); - } -} - -const gatewayWalletAbi = [ - { - type: "function", - name: "deposit", - inputs: [ - { name: "token", type: "address", internalType: "address" }, - { name: "value", type: "uint256", internalType: "uint256" }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "initiateWithdrawal", - inputs: [ - { name: "token", type: "address", internalType: "address" }, - { name: "value", type: "uint256", internalType: "uint256" }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "withdraw", - inputs: [{ name: "token", type: "address", internalType: "address" }], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "availableBalance", - inputs: [ - { name: "depositor", type: "address", internalType: "address" }, - { name: "token", type: "address", internalType: "address" }, - ], - outputs: [{ name: "", type: "uint256", internalType: "uint256" }], - stateMutability: "view", - }, - { - type: "function", - name: "withdrawingBalance", - inputs: [ - { name: "depositor", type: "address", internalType: "address" }, - { name: "token", type: "address", internalType: "address" }, - ], - outputs: [{ name: "", type: "uint256", internalType: "uint256" }], - stateMutability: "view", - }, - { - type: "function", - name: "withdrawableBalance", - inputs: [ - { name: "depositor", type: "address", internalType: "address" }, - { name: "token", type: "address", internalType: "address" }, - ], - outputs: [{ name: "", type: "uint256", internalType: "uint256" }], - stateMutability: "view", - }, - { - type: "function", - name: "withdrawalBlock", - inputs: [ - { name: "depositor", type: "address", internalType: "address" }, - { name: "token", type: "address", internalType: "address" }, - ], - outputs: [{ name: "", type: "uint256", internalType: "uint256" }], - stateMutability: "view", - }, - { - type: "function", - name: "withdrawalDelay", - inputs: [], - outputs: [{ name: "", type: "uint256", internalType: "uint256" }], - stateMutability: "view", - }, - { - type: "function", - name: "addDelegate", - inputs: [ - { name: "token", type: "address", internalType: "address" }, - { name: "delegate", type: "address", internalType: "address" }, - ], - outputs: [], - stateMutability: "nonpayable", - }, - { - type: "function", - name: "removeDelegate", - inputs: [ - { name: "token", type: "address", internalType: "address" }, - { name: "delegate", type: "address", internalType: "address" }, - ], - outputs: [], - stateMutability: "nonpayable", - }, -] as const; - -const gatewayMinterAbi = [ - { - type: "function", - name: "gatewayMint", - inputs: [ - { name: "attestationPayload", type: "bytes", internalType: "bytes" }, - { name: "signature", type: "bytes", internalType: "bytes" }, - ], - outputs: [], - stateMutability: "nonpayable", - }, -] as const; - -const EIP712Domain = [ - { name: "name", type: "string" }, - { name: "version", type: "string" }, -] as const; - -const TransferSpec = [ - { name: "version", type: "uint32" }, - { name: "sourceDomain", type: "uint32" }, - { name: "destinationDomain", type: "uint32" }, - { name: "sourceContract", type: "bytes32" }, - { name: "destinationContract", type: "bytes32" }, - { name: "sourceToken", type: "bytes32" }, - { name: "destinationToken", type: "bytes32" }, - { name: "sourceDepositor", type: "bytes32" }, - { name: "destinationRecipient", type: "bytes32" }, - { name: "sourceSigner", type: "bytes32" }, - { name: "destinationCaller", type: "bytes32" }, - { name: "value", type: "uint256" }, - { name: "salt", type: "bytes32" }, - { name: "hookData", type: "bytes" }, -] as const; - -const BurnIntent = [ - { name: "maxBlockHeight", type: "uint256" }, - { name: "maxFee", type: "uint256" }, - { name: "spec", type: "TransferSpec" }, -] as const; - -function addressToBytes32(address: Address): `0x${string}` { - return pad(address.toLowerCase() as Address, { size: 32 }); -} - -interface BurnIntentSpec { - version: number; - sourceDomain: number; - destinationDomain: number; - sourceContract: Address; - destinationContract: Address; - sourceToken: Address; - destinationToken: Address; - sourceDepositor: Address; - destinationRecipient: Address; - sourceSigner: Address; - destinationCaller: Address; - value: bigint; - salt: `0x${string}`; - hookData: `0x${string}`; -} - -interface BurnIntentData { - maxBlockHeight: bigint; - maxFee: bigint; - spec: BurnIntentSpec; -} - -function burnIntentTypedData(burnIntent: BurnIntentData) { - const domain = { - name: "GatewayWallet", - version: "1", - }; - return { - types: { EIP712Domain, TransferSpec, BurnIntent }, - domain, - primaryType: "BurnIntent" as const, - message: { - ...burnIntent, - spec: { - ...burnIntent.spec, - sourceContract: addressToBytes32(burnIntent.spec.sourceContract), - destinationContract: addressToBytes32(burnIntent.spec.destinationContract), - sourceToken: addressToBytes32(burnIntent.spec.sourceToken), - destinationToken: addressToBytes32(burnIntent.spec.destinationToken), - sourceDepositor: addressToBytes32(burnIntent.spec.sourceDepositor), - destinationRecipient: addressToBytes32(burnIntent.spec.destinationRecipient), - sourceSigner: addressToBytes32(burnIntent.spec.sourceSigner), - destinationCaller: addressToBytes32(burnIntent.spec.destinationCaller), - }, - }, - }; -} - -interface ChallengeResponse { - id: string; -} - -async function waitForTransactionConfirmation(challengeId: string): Promise { - while (true) { - const response = await circleDeveloperSdk.getTransaction({ id: challengeId }); - const tx = response.data?.transaction; - - if (tx?.state === "CONFIRMED" || tx?.state === "COMPLETE") { - console.log(`Transaction ${challengeId} reached terminal state '${tx.state}' with hash: ${tx.txHash}`); - if (!tx.txHash) { - throw new Error(`Transaction ${challengeId} is ${tx.state} but txHash is missing.`); - } - return tx.txHash; - } else if (tx?.state === "FAILED") { - console.error("Circle API Error:", tx); - throw new Error(`Transaction ${challengeId} failed with reason: ${tx.errorReason}`); - } - - console.log(`Transaction ${challengeId} state: ${tx?.state}. Polling again in 2s...`); - await new Promise(resolve => setTimeout(resolve, 2000)); - } -} - -async function initiateContractInteraction( - walletId: string, - contractAddress: Address, - abiFunctionSignature: string, - args: any[] -): Promise { - const response = await circleDeveloperSdk.createContractExecutionTransaction({ - walletId, - contractAddress, - abiFunctionSignature, - abiParameters: args, - fee: { - type: "level", - config: { - feeLevel: "HIGH", - }, - } - }); - - const responseData = response.data as unknown as ChallengeResponse; - - if (!responseData?.id) { - console.error("Circle API Error: Challenge ID not found in response", response.data); - throw new Error("Circle API did not return a Challenge ID."); - } - - return responseData.id; -} - -export async function initiateDepositFromCustodialWallet( - walletId: string, - chain: SupportedChain, - amountInAtomicUnits: bigint -): Promise { - const usdcAddress = USDC_ADDRESSES[chain]; - - console.log(`Step 1: Approving Gateway contract for wallet ${walletId}...`); - const approvalChallengeId = await initiateContractInteraction( - walletId, - usdcAddress as Address, - "approve(address,uint256)", - [GATEWAY_WALLET_ADDRESS, amountInAtomicUnits.toString()] - ); - - console.log(`Step 2: Waiting for approval transaction (Challenge ID: ${approvalChallengeId}) to confirm...`); - await waitForTransactionConfirmation(approvalChallengeId); - - console.log(`Step 3: Calling deposit function on Gateway for wallet ${walletId}...`); - const depositChallengeId = await initiateContractInteraction( - walletId, - GATEWAY_WALLET_ADDRESS as Address, - "deposit(address,uint256)", - [usdcAddress, amountInAtomicUnits.toString()] - ); - - console.log(`Step 4: Waiting for deposit transaction (Challenge ID: ${depositChallengeId}) to confirm...`); - const depositTxHash = await waitForTransactionConfirmation(depositChallengeId); - - console.log("Custodial deposit successful. Final TxHash:", depositTxHash); - return depositTxHash; -} - -export async function submitBurnIntent( - burnIntent: any, - signature: `0x${string}` -): Promise<{ - attestation: `0x${string}`; - attestationSignature: `0x${string}`; - transferId: string; - fees: any; -}> { - const payload = [ - { - burnIntent: { - maxBlockHeight: burnIntent.maxBlockHeight.toString(), - maxFee: burnIntent.maxFee.toString(), - spec: { - ...burnIntent.spec, - value: burnIntent.spec.value.toString(), - }, - }, - signature, - }, - ]; - - const response = await fetch("https://gateway-api-testnet.circle.com/v1/transfer", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Gateway API error: ${response.status} - ${errorText}`); - } - - const data = await response.json(); - const result = Array.isArray(data) ? data[0] : data; - return { - attestation: result.attestation as `0x${string}`, - attestationSignature: result.signature as `0x${string}`, - transferId: result.transferId, - fees: result.fees, - }; -} - -async function getCircleWalletAddress(walletId: string): Promise
{ - const response = await circleDeveloperSdk.getWallet({ id: walletId }); - if (!response.data?.wallet?.address) { - throw new Error(`Could not fetch address for wallet ID: ${walletId}`); - } - return response.data.wallet.address as Address; -} - -async function signBurnIntentCircle( - walletId: string, - burnIntentData: BurnIntentData -): Promise<`0x${string}`> { - const typedData = burnIntentTypedData(burnIntentData); - - const serializedData = JSON.stringify(typedData, (_key, value) => - typeof value === "bigint" ? value.toString() : value - ); - - const response = await circleDeveloperSdk.signTypedData({ - walletId, - data: serializedData, - }); - - const signature = response.data?.signature; - - if (!signature) { - throw new Error("Failed to retrieve signature from Circle API."); - } - - return signature as `0x${string}`; -} - -// Helper to execute mint specifically on a target blockchain using walletAddress (as per reference script) -async function executeMintCircle( - walletAddress: Address, - destinationChain: SupportedChain, - attestation: string, - signature: string -): Promise { - const blockchain = CIRCLE_CHAIN_NAMES[destinationChain]; - if (!blockchain) throw new Error(`No Circle blockchain mapping for ${destinationChain}`); - - const response = await circleDeveloperSdk.createContractExecutionTransaction({ - walletAddress, - blockchain, - contractAddress: GATEWAY_MINTER_ADDRESS, - abiFunctionSignature: "gatewayMint(bytes,bytes)", - abiParameters: [attestation, signature], - fee: { - type: "level", - config: { feeLevel: "MEDIUM" }, - }, - }); - - const challengeId = response.data?.id; - if (!challengeId) throw new Error("Failed to initiate minting challenge"); - - return await waitForTransactionConfirmation(challengeId); -} -export async function transferUnifiedBalanceCircle( - walletId: string, - amount: bigint, - sourceChain: SupportedChain, - destinationChain: SupportedChain, - recipientAddress?: Address -): Promise<{ - burnTxHash: Hash; - attestation: `0x${string}`; - mintTxHash: Hash; -}> { - - // 1. Get Wallet Address - const walletAddress = await getCircleWalletAddress(walletId); - const recipient = recipientAddress || walletAddress; - - // 2. Construct Burn Intent - const burnIntentData: BurnIntentData = { - maxBlockHeight: maxUint256, - maxFee: BigInt(1_010_000), - spec: { - version: 1, - sourceDomain: DOMAIN_IDS[sourceChain], - destinationDomain: DOMAIN_IDS[destinationChain], - sourceContract: GATEWAY_WALLET_ADDRESS as Address, - destinationContract: GATEWAY_MINTER_ADDRESS as Address, - sourceToken: USDC_ADDRESSES[sourceChain] as Address, - destinationToken: USDC_ADDRESSES[destinationChain] as Address, - sourceDepositor: walletAddress, - destinationRecipient: recipient, - sourceSigner: walletAddress, - destinationCaller: zeroAddress, - value: amount, - salt: `0x${randomBytes(32).toString("hex")}` as `0x${string}`, - hookData: "0x" as `0x${string}`, - }, - }; - - // 3. Sign Intent (Custodial) - const signature = await signBurnIntentCircle(walletId, burnIntentData); - - // 4. Submit to Gateway - // (We need to regenerate typedData here just to get the 'message' part for the submission payload) - const typedData = burnIntentTypedData(burnIntentData); - - const { attestation, attestationSignature, transferId } = await submitBurnIntent( - typedData.message, - signature - ); - - console.log(`Transfer submitted. ID: ${transferId}. Polling for attestation...`); - - // 5. Poll for Attestation - let finalAttestation = attestation; - let finalSignature = attestationSignature; - - if (!finalAttestation || !finalSignature) { - while (true) { - await new Promise((r) => setTimeout(r, 3000)); // Wait 3s - - const pollResponse = await fetch(`https://gateway-api-testnet.circle.com/v1/transfers/${transferId}`); - const pollJson = await pollResponse.json(); - const status = pollJson.status || pollJson.state; - - console.log(`Transfer Status: ${status}`); - - if (pollJson.attestation && pollJson.signature) { - finalAttestation = pollJson.attestation; - finalSignature = pollJson.signature; - break; - } else if (status === "FAILED") { - throw new Error(`Transfer failed on Gateway: ${JSON.stringify(pollJson)}`); - } - } - } - - // 6. Execute Mint on Destination (Custodial) - const mintTxHash = await executeMintCircle( - walletAddress, - destinationChain, - finalAttestation, - finalSignature - ); - - return { - burnTxHash: "0x" as Hash, - attestation: finalAttestation, - mintTxHash: mintTxHash as Hash, - }; -} - -export async function fetchGatewayBalance(address: Address): Promise<{ - token: string; - balances: Array<{ domain: number; depositor: string; balance: string }>; -}> { - const sources = [ - { domain: DOMAIN_IDS.arcTestnet, depositor: address }, - { domain: DOMAIN_IDS.avalancheFuji, depositor: address }, - { domain: DOMAIN_IDS.baseSepolia, depositor: address }, - ]; - - const requestBody = { - token: "USDC", - sources, - }; - - const response = await fetch("https://gateway-api-testnet.circle.com/v1/balances", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Gateway API error: ${response.status} - ${errorText}`); - } - - const data = await response.json(); - return data; -} - -export async function getUsdcBalance( - address: Address, - chain: SupportedChain -): Promise { - const publicClient = createPublicClient({ - chain: getChainConfig(chain), - transport: http(), - }); - - const balance = await publicClient.readContract({ - address: USDC_ADDRESSES[chain] as Address, - abi: erc20Abi, - functionName: "balanceOf", - args: [address], - }); - - return balance as bigint; -} - -export async function fetchGatewayInfo(): Promise<{ - version: number; - domains: Array<{ - chain: string; - network: string; - domain: number; - walletContract: { address: string; supportedTokens: string[] }; - minterContract: { address: string; supportedTokens: string[] }; - processedHeight: string; - burnIntentExpirationHeight: string; - }>; -}> { - const response = await fetch("https://gateway-api-testnet.circle.com/v1/info", { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Gateway API error: ${response.status} - ${error}`); - } - - const data = await response.json(); - return data; -} \ No newline at end of file diff --git a/lib/circle/initialize-admin-wallet.ts b/lib/circle/initialize-admin-wallet.ts new file mode 100644 index 0000000..5e04534 --- /dev/null +++ b/lib/circle/initialize-admin-wallet.ts @@ -0,0 +1,182 @@ +/** + * 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 { createClient } from "@supabase/supabase-js"; +import { Database } from "@/types/supabase"; + +const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + : "http://localhost:3000"; + +// Supabase Admin Client Initialization +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +// This in-memory flag ensures the initialization logic runs only once per server start. +let isInitialized = false; + +/** + * An idempotent, self-invoking async function that creates the primary platform + * admin wallet if it does not already exist in the database. + */ +const runPlatformInitialization = async () => { + // 1. Check the in-memory flag to prevent redundant runs. + if (isInitialized) { + return; + } + + console.log("Running platform initialization check..."); + + if (!supabaseUrl || !supabaseServiceRoleKey) { + console.error( + "Supabase URL or Service Role Key is not set. Initialization cannot proceed." + ); + + // Mark as initialized to prevent retries + isInitialized = true; + return; + } + + const supabaseAdminClient = createClient( + supabaseUrl, + supabaseServiceRoleKey + ); + + const ADMIN_WALLET_LABEL = "Primary wallet"; + + try { + // 2. Check the database to see if the wallet already exists. + // We check for the existence of ANY wallet with the specific label. + const { data: existingWallet, error: fetchError } = await supabaseAdminClient + .from("admin_wallets") + .select("circle_wallet_id") + .eq("label", ADMIN_WALLET_LABEL) + // Use maybeSingle to handle 0 or 1 row gracefully + .maybeSingle(); + + if (fetchError && fetchError.code !== "PGRST116") { + // Provide more context about the error + const errorMessage = fetchError.message || "Unknown error"; + const errorCode = fetchError.code || "Unknown code"; + const errorDetails = fetchError.details || ""; + throw new Error( + `Supabase fetch error: ${errorMessage} (Code: ${errorCode}${errorDetails ? `, Details: ${errorDetails}` : ""})` + ); + } + + if (existingWallet) { + console.log( + `Platform admin wallet already exists. ID: ${existingWallet.circle_wallet_id}. Initialization complete.` + ); + isInitialized = true; + return; + } + + // 3. If not in DB, create it by calling our internal API routes. + console.log("No platform admin wallet found. Creating a new one..."); + + const createdWalletSetResponse = await fetch(`${baseUrl}/api/wallet-set`, { + method: "POST", + body: JSON.stringify({ entityName: "platform-operator" }), + headers: { "Content-Type": "application/json" }, + }); + + if (!createdWalletSetResponse.ok) { + const errorBody = await createdWalletSetResponse.json(); + throw new Error( + `Failed to create wallet set via internal API: ${errorBody.error || "Unknown error" + }` + ); + } + const createdWalletSet = await createdWalletSetResponse.json(); + + const createdWalletResponse = await fetch(`${baseUrl}/api/wallet`, { + method: "POST", + body: JSON.stringify({ walletSetId: createdWalletSet.id }), + headers: { "Content-Type": "application/json" }, + }); + + if (!createdWalletResponse.ok) { + const errorBody = await createdWalletResponse.json(); + throw new Error( + `Failed to create wallet via internal API: ${errorBody.error || "Unknown error" + }` + ); + } + const newWallet = await createdWalletResponse.json(); + + if (!newWallet || !newWallet.id || !newWallet.address) { + throw new Error("Internal API did not return a complete wallet object."); + } + + // 4. Store the new wallet details in the new `admin_wallets` table. + const { error: insertError } = await supabaseAdminClient + .from("admin_wallets") + .insert({ + circle_wallet_id: newWallet.id, + label: ADMIN_WALLET_LABEL, + address: newWallet.address, + chain: "ARC-TESTNET", + // All other columns (status, chain, supported_assets) will use their defaults (ENABLED, NULL, NULL) + }); + + if (insertError) { + // If we get a unique constraint violation, it means another process already created the wallet + // This is fine - just log it and move on + if (insertError.code === "23505") { + console.log("Platform admin wallet was created by another process. Initialization complete."); + return; + } + throw new Error( + `Failed to save new admin wallet to Supabase: ${insertError.message}` + ); + } + + console.log( + `Successfully created and saved new admin wallet. ID: ${newWallet.id}` + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + + // Check if it's a network/connection error + if ( + errorMessage.includes("invalid response") || + errorMessage.includes("fetch failed") || + errorMessage.includes("ECONNREFUSED") || + errorMessage.includes("ENOTFOUND") + ) { + console.warn( + "⚠️ Platform initialization skipped: Unable to connect to Supabase.", + "This may be due to network issues or Supabase service being unavailable.", + "The app will continue to run, but admin wallet initialization will be retried on next server restart." + ); + } else { + console.error( + "❌ Error during platform initialization:", + errorMessage, + errorStack ? `\nStack: ${errorStack}` : "" + ); + } + } finally { + // Mark as initialized even if there was an error to prevent constant retries. + isInitialized = true; + } +}; + +runPlatformInitialization(); \ No newline at end of file diff --git a/lib/circle/permit.ts b/lib/circle/permit.ts index d462bb5..e7a90e2 100644 --- a/lib/circle/permit.ts +++ b/lib/circle/permit.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. diff --git a/lib/deposit.ts b/lib/deposit.ts deleted file mode 100644 index 4d69ab7..0000000 --- a/lib/deposit.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Copyright 2026 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 - */ - -// lib/deposit.ts -import { createClient } from '@/lib/supabase/server'; - -export type DepositParams = { - userId?: string; - chain?: string; - amount?: number; -}; - -export type DepositResult = - | { success: true; depositResult: any } - | { error: string }; - -export async function handleDeposit( - params: DepositParams, - supabaseClient?: any -): Promise { - const { userId, chain, amount } = params; - - // Validate required fields - if (!userId) { - return { error: 'Missing userId' }; - } - if (!chain) { - return { error: 'Missing chain' }; - } - if (amount === undefined || amount === null) { - return { error: 'Missing amount' }; - } - - // TODO: Integrate Circle Paymaster API for gas abstraction - // Placeholder for deposit logic with gas paid in USDC - const depositResult = { - txHash: 'mock-tx-hash', - gatewayWalletAddress: 'mock-gateway-wallet-address', - gasPaidWithUSDC: true, - }; - - // Store transaction in Supabase - try { - const supabase = supabaseClient || await createClient(); - await supabase - .from('transaction_history') - .insert([{ - user_id: userId, - chain, - tx_type: 'deposit', - amount, - tx_hash: depositResult.txHash, - gateway_wallet_address: depositResult.gatewayWalletAddress, - created_at: new Date().toISOString(), - }]); - } catch (e) { - // For unit tests, ignore Supabase errors - } - - return { success: true, depositResult }; -} diff --git a/lib/mock-data.ts b/lib/mock-data.ts new file mode 100644 index 0000000..4be3632 --- /dev/null +++ b/lib/mock-data.ts @@ -0,0 +1,95 @@ +/** + * 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 { addDays, subDays } from "date-fns"; + +// Define the shape of our data +export type TransactionStatus = "pending" | "confirmed" | "failed"; +export type Network = "Ethereum" | "Polygon" | "Base"; + +export interface TransactionEvent { + id: string; + timestamp: Date; + status: TransactionStatus; + description: string; +} + +export interface PurchaseTransaction { + id: string; + date: Date; + credits: number; + usdcPaid: number; + fee: number; + status: TransactionStatus; + network: Network; + txHash: string; + events: TransactionEvent[]; +} + +const statuses: TransactionStatus[] = ["confirmed", "pending", "failed"]; +const networks: Network[] = ["Ethereum", "Polygon", "Base"]; + +function createMockTransaction(id: number): PurchaseTransaction { + const status = statuses[id % statuses.length]; + + // Use a fixed start date + const date = subDays(new Date("2025-09-25T10:00:00Z"), id * 3); + + // Generate predictable values using the ID to ensure they are the same on server and client. + const usdcPaid = parseFloat((((id * 13.37) % 200) + 10).toFixed(2)); + + // Simple, predictable hash + const txHash = `0x${id.toString(16).padStart(4, "0")}${"a".repeat(60)}`; + + const fee = parseFloat((usdcPaid * 0.01).toFixed(2)); + const credits = Math.floor(usdcPaid); + + return { + id: `txn_${id}`, + date, + credits, + usdcPaid, + fee, + status, + network: networks[id % networks.length], + txHash, + events: [ + { + id: `evt1_${id}`, + timestamp: date, + status: "pending", + description: "Transaction initiated by user.", + }, + ...(status !== "pending" + ? [ + { + id: `evt2_${id}`, + timestamp: addDays(date, 1), + status: status, + description: `Transaction ${status} on-chain.`, + }, + ] + : []), + ], + }; +} + +export const mockTransactions: PurchaseTransaction[] = Array.from( + { length: 50 }, + (_, i) => createMockTransaction(i + 1) +); \ No newline at end of file diff --git a/lib/supabase/admin-client.ts b/lib/supabase/admin-client.ts new file mode 100644 index 0000000..9a4b6cc --- /dev/null +++ b/lib/supabase/admin-client.ts @@ -0,0 +1,37 @@ +/** + * 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 { createClient } from '@supabase/supabase-js'; + +/** + * Server-side admin Supabase client using the service role key. + * Never import this into client components. + */ +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +export const supabaseAdminClient = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE_KEY, + { + auth: { + persistSession: false, + autoRefreshToken: false, + }, + } +); \ No newline at end of file diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts index b787d67..a58075f 100644 --- a/lib/supabase/client.ts +++ b/lib/supabase/client.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -21,6 +21,6 @@ import { createBrowserClient } from "@supabase/ssr"; export function createClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!, ); } diff --git a/lib/supabase/initialize-admin-user.ts b/lib/supabase/initialize-admin-user.ts new file mode 100644 index 0000000..614616e --- /dev/null +++ b/lib/supabase/initialize-admin-user.ts @@ -0,0 +1,115 @@ +/** + * 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 { createClient } from "@supabase/supabase-js"; +import { SupabaseClient } from "@supabase/supabase-js"; + +// These environment variables must be available on your server. +// You should have them in a .env.local file for local development. +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +// A server-side-only, admin client for Supabase. +let adminAuthClient: SupabaseClient | null = null; + +if (supabaseUrl && supabaseServiceRoleKey) { + adminAuthClient = createClient(supabaseUrl, supabaseServiceRoleKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); +} else { + console.warn( + "Supabase URL or Service Role Key is not set. Admin user creation will be skipped." + ); +} + +const createAdminUserIfNotExists = async () => { + if (!adminAuthClient) { + return; + } + + const adminEmail = "admin@admin.com"; + const adminPassword = "123456"; + + // We call our custom database function via RPC (Remote Procedure Call). + // This is a single, fast, and scalable database query. + const { data: adminUserExists, error: rpcError } = await adminAuthClient.rpc( + "check_user_exists", + { user_email: adminEmail } + ); + + if (rpcError) { + const errorMessage = rpcError.message || "Unknown error"; + const errorCode = rpcError.code || "Unknown code"; + const errorDetails = rpcError.details || ""; + + // Check if it's a network/connection error + if ( + errorMessage.includes("invalid response") || + errorMessage.includes("fetch failed") || + errorMessage.includes("ECONNREFUSED") || + errorMessage.includes("ENOTFOUND") + ) { + console.warn( + "⚠️ Admin user initialization skipped: Unable to connect to Supabase.", + "This may be due to network issues or Supabase service being unavailable.", + "The app will continue to run, but admin user initialization will be retried on next server restart." + ); + } else { + console.error( + "❌ Error checking for admin user:", + `${errorMessage} (Code: ${errorCode}${errorDetails ? `, Details: ${errorDetails}` : ""})` + ); + } + return; + } + + if (adminUserExists) { + console.log( + `Admin user with email ${adminEmail} already exists. Skipping creation.` + ); + return; + } + + // If the RPC call returns false, we create the user. + console.log( + `Admin user not found. Creating user with email ${adminEmail}...` + ); + const { data, error: createError } = + await adminAuthClient.auth.admin.createUser({ + email: adminEmail, + password: adminPassword, + // Automatically confirm the user's email + email_confirm: true, + }); + + if (createError) { + console.error("Error creating admin user:", createError.message); + return; + } + + if (data.user) { + console.log(`Admin user with email ${adminEmail} created successfully.`); + } +}; + +// This is the key: we call the function immediately. +// When this file is imported, this function will run. +createAdminUserIfNotExists(); \ No newline at end of file diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index e97b85c..ba897e0 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -35,7 +35,7 @@ export async function updateSession(request: NextRequest) { // variable. Always create a new one on each request. const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!, { cookies: { getAll() { diff --git a/lib/supabase/server-client.ts b/lib/supabase/server-client.ts index 78a051c..6cac488 100644 --- a/lib/supabase/server-client.ts +++ b/lib/supabase/server-client.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -25,7 +25,7 @@ export function createSupabaseReqResClient( ) { return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL, - process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY, { cookies: { getAll() { diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts index 75e1504..42b09fa 100644 --- a/lib/supabase/server.ts +++ b/lib/supabase/server.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -19,26 +19,33 @@ import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; -/** - * Especially important if using Fluid compute: Don't put this client in a - * global variable. Always create a new client within each function when using - * it. - */ +// This import triggers your admin user creation script on server startup. +import "@/lib/supabase/initialize-admin-user"; + +// Import the new Circle platform operator wallet creation script +import "@/lib/circle/initialize-admin-wallet"; + export async function createClient() { const cookieStore = await cookies(); return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!, { + // This is the corrected cookies object that resolves the deprecation warning. cookies: { + // The new `getAll` method should return all cookies. + // The `cookies()` function from `next/headers` provides a `getAll()` method that + // returns cookies in the exact format needed: an array of { name, value }. getAll() { return cookieStore.getAll(); }, + // The new `setAll` method receives an array of cookies to set. + // We need to loop through this array and call `cookieStore.set()` for each one. setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => - cookieStore.set(name, value, options), + cookieStore.set(name, value, options) ); } catch { // The `setAll` method was called from a Server Component. @@ -47,6 +54,6 @@ export async function createClient() { } }, }, - }, + } ); } diff --git a/lib/utils.ts b/lib/utils.ts index 47242cb..d656a1a 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -26,4 +26,4 @@ export function cn(...inputs: ClassValue[]) { // This check can be removed, it is just for tutorial purposes export const hasEnvVars = process.env.NEXT_PUBLIC_SUPABASE_URL && - process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY; diff --git a/lib/utils/chain-utils.ts b/lib/utils/chain-utils.ts new file mode 100644 index 0000000..40becf1 --- /dev/null +++ b/lib/utils/chain-utils.ts @@ -0,0 +1,78 @@ +/** + * 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 { SupportedChainId, CHAIN_TO_CHAIN_NAME } from "@/lib/chains"; + +/** + * Converts a chain ID (stored as string in DB) to a human-readable network name + */ +export function getNetworkName(chainId: string | number): string { + const numericChainId = typeof chainId === 'string' ? parseInt(chainId, 10) : chainId; + + if (isNaN(numericChainId)) { + return `Chain ${chainId}`; + } + + return CHAIN_TO_CHAIN_NAME[numericChainId] || `Chain ${chainId}`; +} + +/** + * Converts a chain name (like "ARC-TESTNET") to numeric chain ID + */ +export function chainNameToId(chainName: string): number | undefined { + const chainKey = chainName.replace(/-/g, '_'); + return SupportedChainId[chainKey as keyof typeof SupportedChainId]; +} + +/** + * Converts a numeric chain ID to chain name (like "ARC-TESTNET") + */ +export function chainIdToName(chainId: number): string | undefined { + const chainIdToNameMap: Record = { + [SupportedChainId.ETH_SEPOLIA]: "ETH-SEPOLIA", + [SupportedChainId.AVAX_FUJI]: "AVAX-FUJI", + [SupportedChainId.BASE_SEPOLIA]: "BASE-SEPOLIA", + [SupportedChainId.ARC_TESTNET]: "ARC-TESTNET" + }; + return chainIdToNameMap[chainId]; +} + +/** + * Gets explorer URL for a transaction hash on a given chain + */ +export function getExplorerUrl(chainId: string | number, txHash?: string, address?: string): string | null { + const numericChainId = typeof chainId === 'string' ? parseInt(chainId, 10) : chainId; + + const explorerBaseUrls: Record = { + [SupportedChainId.ETH_SEPOLIA]: "https://sepolia.etherscan.io", + [SupportedChainId.AVAX_FUJI]: "https://testnet.snowtrace.io", + [SupportedChainId.BASE_SEPOLIA]: "https://sepolia.basescan.org", + [SupportedChainId.ARC_TESTNET]: "https://testnet.arcscan.app" + }; + + const baseUrl = explorerBaseUrls[numericChainId]; + if (!baseUrl) return null; + + if (txHash) { + return `${baseUrl}/tx/${txHash}`; + } else if (address) { + return `${baseUrl}/address/${address}`; + } + + return baseUrl; +} diff --git a/lib/utils/convert-to-smallest-unit.ts b/lib/utils/convert-to-smallest-unit.ts new file mode 100644 index 0000000..e8dad94 --- /dev/null +++ b/lib/utils/convert-to-smallest-unit.ts @@ -0,0 +1,38 @@ +/** + * 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 + */ + +/** + * Converts a USDC amount string (e.g., "1.50") to its smallest unit as an integer. + * USDC has 6 decimal places, so 1 USDC = 1,000,000 of its smallest unit. + * + * @param amount The amount of USDC as a string (e.g., "1", "0.01"). + * @returns The amount in the smallest unit as a number. + */ +export function convertToSmallestUnit(amount: string): number { + // The number of decimal places for USDC + const usdcDecimals = 6; + + // The multiplier to convert from whole units to the smallest unit + const multiplier = 10 ** usdcDecimals; // This is 1,000,000 + + // Parse the string amount to a float and multiply + const amountInSmallestUnit = parseFloat(amount) * multiplier; + + // Return the rounded integer to avoid floating-point inaccuracies + return Math.round(amountInSmallestUnit); +} \ No newline at end of file diff --git a/lib/wagmi/config.ts b/lib/wagmi/config.ts new file mode 100644 index 0000000..284afaa --- /dev/null +++ b/lib/wagmi/config.ts @@ -0,0 +1,90 @@ +/** + * 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 { http, createConfig } from "wagmi"; +import { + type Chain, + mainnet, + sepolia, + base, + baseSepolia, + polygon, + polygonAmoy, + arbitrum, + arbitrumSepolia, + optimism, + optimismSepolia, +} from "wagmi/chains"; +import { injected } from "wagmi/connectors"; + +const arcTestnet = { + id: 5042002, + name: "Arc Testnet", + nativeCurrency: { name: "USDC", symbol: "USDC", decimals: 18 }, + rpcUrls: { + default: { http: ["https://rpc.testnet.arc.network"] }, + }, + blockExplorers: { + default: { name: "Arcscan", url: "https://testnet.arcscan.app/" }, + }, + contracts: { + multicall3: { + address: "0xca11bde05977b3631167028862be2a173976ca11", + }, + }, +} as const satisfies Chain; + +/** + * Ordered list of supported chains shown to users. + * Testnets are included for development; you can filter them out in prod if desired. + */ +export const SUPPORTED_CHAINS = [ + mainnet, + base, + polygon, + arbitrum, + optimism, + // Testnets (optionally hide in production) + sepolia, + baseSepolia, + polygonAmoy, + arbitrumSepolia, + optimismSepolia, + arcTestnet +] as const; + +export const DEFAULT_CHAIN = mainnet; + +export const wagmiConfig = createConfig({ + chains: SUPPORTED_CHAINS, + connectors: [injected()], + transports: { + [mainnet.id]: http(), + [base.id]: http(), + [polygon.id]: http(), + [arbitrum.id]: http(), + [optimism.id]: http(), + [sepolia.id]: http(), + [baseSepolia.id]: http(), + [polygonAmoy.id]: http(), + [arbitrumSepolia.id]: http(), + [optimismSepolia.id]: http(), + [arcTestnet.id]: http() + }, + ssr: true, +}); diff --git a/lib/wagmi/telemetry.ts b/lib/wagmi/telemetry.ts new file mode 100644 index 0000000..b4b1b3b --- /dev/null +++ b/lib/wagmi/telemetry.ts @@ -0,0 +1,81 @@ +/** + * 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 + */ + +type NetworkEventName = + | "switch_attempt" + | "switch_no_capability" + | "switch_missing_config" + | "switch_success" + | "switch_failure" + | "auto_switch_trigger" + | "preferred_switch_trigger" + | "preferred_switch_trigger_initial" + | "preferred_chain_updated_external" + | "auto_switch_pref_changed"; + +interface NetworkEventPayload { + [key: string]: unknown; + dest?: number; + from?: number; + to?: number; + error?: string; + raw?: string; + enabled?: boolean; + connected?: boolean; + canSwitch?: boolean; +} + +interface BufferedEvent { + ts: number; + name: NetworkEventName; + data: NetworkEventPayload; +} + +const buffer: BufferedEvent[] = []; +const MAX_BUFFER = 50; +let flushTimer: ReturnType | null = null; + +function scheduleFlush() { + if (flushTimer) return; + flushTimer = setTimeout(() => { + flushTimer = null; + flush(); + }, 5000); +} + +function flush() { + if (!buffer.length) return; + // Placeholder: replace with network request to your backend / analytics. + if (process.env.NODE_ENV !== "production") { + console.debug("[telemetry] network events", [...buffer]); + } + buffer.length = 0; +} + +export function emitNetworkEvent( + name: NetworkEventName, + data: NetworkEventPayload = {} +) { + buffer.push({ ts: Date.now(), name, data }); + if (buffer.length >= MAX_BUFFER) flush(); + else scheduleFlush(); +} + +export function forceFlushNetworkEvents() { + flush(); +} diff --git a/lib/wagmi/usdcAddresses.ts b/lib/wagmi/usdcAddresses.ts index 9bc97d8..17ab753 100644 --- a/lib/wagmi/usdcAddresses.ts +++ b/lib/wagmi/usdcAddresses.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -17,33 +17,43 @@ */ import { - arbitrum, - arbitrumSepolia, - base, - baseSepolia, mainnet, - optimism, - optimismSepolia, + base, polygon, - polygonAmoy, + arbitrum, + optimism, + baseSepolia, sepolia, + polygonAmoy, + arbitrumSepolia, + optimismSepolia, } from "wagmi/chains"; -import { type Address } from "viem"; -const USDC_ADDRESSES: Record = { - [mainnet.id]: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", - [sepolia.id]: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", - [polygon.id]: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", - [polygonAmoy.id]: "0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582", +/** + * Canonical USDC (or primary deployment) addresses (6 decimals). + * Includes selected testnets for development. + */ +export const USDC_ADDRESSES: Record = { + // Mainnets + [mainnet.id]: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + [base.id]: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + [polygon.id]: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", [arbitrum.id]: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", - [arbitrumSepolia.id]: "0x75faf114e59ef0dcf08a42114540d75edb2a072d", - [optimism.id]: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", - [optimismSepolia.id]: "0x5fd84259d66Cd46123540776Be934041f3606881", - [base.id]: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + // Testnets [baseSepolia.id]: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + // Optional additional testnets (placeholder or known deploys) + [sepolia.id]: "0xd6c3a3a6B523b3f30c1e03DF621cBe03b12E0A35", + [polygonAmoy.id]: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + // Updated to official Arbitrum Sepolia USDC + [arbitrumSepolia.id]: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", + [optimismSepolia.id]: "0x09D1D7d6b9d4B4b0597F64E299fB2C89F76DdF24", + 5042002: "0x3600000000000000000000000000000000000000", }; -export function getUsdcAddress(chainId: number): Address | undefined { - return USDC_ADDRESSES[chainId]; +/** + * Helper to get USDC address (returns undefined if unsupported). + */ +export function getUsdcAddress(chainId?: number) { + return chainId ? USDC_ADDRESSES[chainId] : undefined; } diff --git a/lib/wagmi/useNetworkSupport.ts b/lib/wagmi/useNetworkSupport.ts new file mode 100644 index 0000000..652f583 --- /dev/null +++ b/lib/wagmi/useNetworkSupport.ts @@ -0,0 +1,105 @@ +/** + * 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 + */ + +"use client"; + +import { useMemo } from "react"; +import { useAccount, useChainId, useSwitchChain } from "wagmi"; +import { SUPPORTED_CHAINS } from "@/lib/wagmi/config"; + +const MAINNET_CHAINS = SUPPORTED_CHAINS.filter( + (c) => !/sepolia|amoy/i.test(c.name.toLowerCase()) +); +const TESTNET_CHAINS = SUPPORTED_CHAINS.filter((c) => + /sepolia|amoy/i.test(c.name.toLowerCase()) +); + +export interface NetworkStatus { + isConnected: boolean; + currentChainId: number | null; + currentChainName: string | null; + isSupported: boolean; + supportedChainIds: number[]; + unsupportedReason?: string; + isSwitching: boolean; + canSwitch: boolean; + trySwitch: (chainId: number) => Promise<{ ok: boolean; error?: string }>; + mainnets: typeof MAINNET_CHAINS; + testnets: typeof TESTNET_CHAINS; +} + +export function useNetworkSupport(): NetworkStatus { + const { isConnected } = useAccount(); + const chainId = useChainId(); + const { switchChainAsync, isPending: isSwitching } = useSwitchChain(); + + const supportedChainIds = useMemo( + () => SUPPORTED_CHAINS.map((c) => c.id), + [] + ); + + const isConfiguredChainId = (id: number): boolean => + (supportedChainIds as number[]).includes(id); + + const isSupported = chainId != null && isConfiguredChainId(chainId); + + const currentChainName = + chainId != null + ? SUPPORTED_CHAINS.find((c) => c.id === chainId)?.name || "Unknown" + : null; + + const unsupportedReason = + !isSupported && chainId != null + ? "This network is not supported." + : undefined; + + const canSwitch = !!switchChainAsync; + + const trySwitch = async (chainId: number) => { + if (!switchChainAsync) { + return { + ok: false, + error: + "Programmatic network switching disabled. Please switch networks in your wallet.", + }; + } + try { + await switchChainAsync({ chainId }); + return { ok: true }; + } catch (e) { + return { + ok: false, + error: (e as Error)?.message || "Error switching network.", + }; + } + }; + + return { + isConnected, + currentChainId: chainId ?? null, + currentChainName, + isSupported, + supportedChainIds, + unsupportedReason, + isSwitching, + canSwitch, + trySwitch, + mainnets: MAINNET_CHAINS, + testnets: TESTNET_CHAINS, + }; +} diff --git a/lib/wagmi/useUsdcBalance.ts b/lib/wagmi/useUsdcBalance.ts new file mode 100644 index 0000000..f4f0269 --- /dev/null +++ b/lib/wagmi/useUsdcBalance.ts @@ -0,0 +1,86 @@ +/** + * 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 + */ + +"use client"; + +import { useMemo } from "react"; +import { useAccount, useChainId, useReadContract } from "wagmi"; +import { erc20Abi, formatUnits } from "viem"; +import { getUsdcAddress } from "@/lib/wagmi/usdcAddresses"; + +export interface UsdcBalanceResult { + usdcAddress?: `0x${string}`; + balance: bigint | null; + isLoading: boolean; + error?: Error; + formatted: string | null; + hasBalance: boolean | null; + unsupported: boolean; +} + +/** + * Reads the connected wallet's USDC balance on the current chain (if supported). + */ +export function useUsdcBalance(): UsdcBalanceResult { + const { address, isConnected } = useAccount(); + const chainId = useChainId(); + + const usdcAddress = getUsdcAddress(chainId); + const unsupported = Boolean(chainId && !usdcAddress); + const enabled = Boolean(isConnected && address && usdcAddress); + + const { data, isLoading, error } = useReadContract({ + address: usdcAddress, + abi: erc20Abi, + functionName: "balanceOf", + args: address ? [address] : undefined, + chainId, + query: { + enabled, + staleTime: 15_000, + refetchOnWindowFocus: true, + }, + }); + + const { balance, formatted, hasBalance } = useMemo(() => { + if (!data) { + return { + balance: null, + formatted: null, + hasBalance: null as boolean | null, + }; + } + const raw = data as bigint; + const has = raw > 0n; + return { + balance: raw, + formatted: formatUnits(raw, 6), + hasBalance: has, + }; + }, [data]); + + return { + usdcAddress, + balance, + isLoading, + error: error as Error | undefined, + formatted, + hasBalance, + unsupported, + }; +} diff --git a/next.config.ts b/next.config.ts index b13f638..0f442f8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -18,8 +18,6 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = { - /* config options here */ -}; +const nextConfig: NextConfig = {}; export default nextConfig; diff --git a/package.json b/package.json index 060618a..1518e8b 100644 --- a/package.json +++ b/package.json @@ -4,52 +4,59 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "next lint" }, "dependencies": { - "@circle-fin/circle-sdk": "^2.9.0", - "@circle-fin/developer-controlled-wallets": "^9.2.1", - "@circle-fin/smart-contract-platform": "^9.2.1", - "@radix-ui/react-checkbox": "^1.3.3", + "@circle-fin/bridge-kit": "^1.1.2", + "@circle-fin/developer-controlled-wallets": "^8.4.1", + "@circle-fin/modular-wallets-core": "^1.0.11", + "@hookform/resolvers": "^5.2.2", + "@metamask/delegation-toolkit": "^0.13.0", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@supabase/ssr": "latest", "@supabase/supabase-js": "latest", - "@tanstack/react-query": "^5.90.5", - "@wagmi/core": "^2.22.1", + "@tanstack/react-query": "^5.90.2", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.12.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "dotenv": "^17.2.3", - "lucide-react": "^0.548.0", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.511.0", "next": "latest", "next-themes": "^0.4.6", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "react": "^19.0.0", + "react-day-picker": "^9.11.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.63.0", "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", - "uuid": "^13.0.0", - "viem": "^2.38.5", - "wagmi": "^2.19.1" + "tailwind-merge": "^3.3.0", + "viem": "^2.38.0", + "wagmi": "^2.17.5", + "zod": "^4.1.11" }, "devDependencies": { - "@eslint/eslintrc": "^3.3.1", - "@tailwindcss/postcss": "^4.1.16", - "@types/node": "^24.9.2", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", - "eslint": "^9.38.0", - "eslint-config-next": "16.0.1", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.16", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4.1.13", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "baseline-browser-mapping": "^2.9.3", + "eslint": "^9", + "eslint-config-next": "15.3.1", + "postcss": "^8", + "tailwindcss": "^4.1.13", "tailwindcss-animate": "^1.0.7", - "typescript": "^5.9.3" - }, - "packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a" + "typescript": "^5" + } } diff --git a/postcss.config.mjs b/postcss.config.mjs index 89701b0..c47a57e 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. diff --git a/proxy.ts b/proxy.ts index 4b36611..32bcd37 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -16,7 +16,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { type NextRequest, NextResponse } from "next/server"; +import { NextResponse, type NextRequest } from "next/server"; import { createSupabaseReqResClient } from "@/lib/supabase/server-client"; export async function proxy(request: NextRequest) { diff --git a/public/usdc-logo.svg b/public/usdc-logo.svg new file mode 100644 index 0000000..32cd3cf --- /dev/null +++ b/public/usdc-logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/supabase/config.toml b/supabase/config.toml index 18f8609..562aebe 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1,4 +1,4 @@ -# Copyright 2026 Circle Internet Group, Inc. All rights reserved. +# 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. @@ -18,7 +18,7 @@ # https://supabase.com/docs/guides/local-development/cli/config # A string used to distinguish different Supabase projects on the same host. Defaults to the # working directory name when running `supabase init`. -project_id = "multichain-gateway-wallet" +project_id = "top-up" [api] enabled = true @@ -141,8 +141,6 @@ site_url = "http://127.0.0.1:3000" additional_redirect_urls = ["https://127.0.0.1:3000"] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600 -# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). -# jwt_issuer = "" # Path to JWT signing key. DO NOT commit your signing keys file to git. # signing_keys_path = "./signing_keys.json" # If disabled, the refresh token will never expire. @@ -216,12 +214,6 @@ otp_expiry = 3600 # subject = "You have been invited" # content_path = "./supabase/templates/invite.html" -# Uncomment to customize notification email template -# [auth.email.notification.password_changed] -# enabled = true -# subject = "Your password has been changed" -# content_path = "./templates/password_changed_notification.html" - [auth.sms] # Allow/disallow new user signups via SMS to your project. enable_signup = false @@ -299,8 +291,6 @@ redirect_uri = "" url = "" # If enabled, the nonce check will be skipped. Required for local sign in with Google auth. skip_nonce_check = false -# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. -email_optional = false # Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. # You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. @@ -330,17 +320,8 @@ enabled = false # Obtain from https://clerk.com/setup/supabase # domain = "example.clerk.accounts.dev" -# OAuth server configuration -[auth.oauth_server] -# Enable OAuth server functionality -enabled = false -# Path for OAuth consent flow UI -authorization_url_path = "/oauth/consent" -# Allow dynamic client registration -allow_dynamic_registration = false - [edge_runtime] -enabled = true +enabled = false # Supported request policies: `oneshot`, `per_worker`. # `per_worker` (default) — enables hot reload during local development. # `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). diff --git a/supabase/migrations/20250925113303_create_transactions_table.sql b/supabase/migrations/20250925113303_create_transactions_table.sql new file mode 100644 index 0000000..6029954 --- /dev/null +++ b/supabase/migrations/20250925113303_create_transactions_table.sql @@ -0,0 +1,129 @@ +-- 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 + +-- 1. Create Custom Types +CREATE TYPE transaction_direction AS ENUM ('credit', 'debit'); +CREATE TYPE transaction_status AS ENUM ('pending', 'confirmed', 'failed', 'complete'); + +-- 2. Create the `transactions` table +CREATE TABLE public.transactions ( + id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + wallet_id text NOT NULL, + direction transaction_direction NOT NULL, + amount_usdc numeric(18, 6) NOT NULL CHECK (amount_usdc > 0), + fee_usdc numeric(18, 6) NOT NULL DEFAULT 0 CHECK (fee_usdc >= 0), + credit_amount numeric(18, 6) NOT NULL CHECK (credit_amount > 0), + exchange_rate numeric(18, 8) NOT NULL CHECK (exchange_rate > 0), + chain text NOT NULL, + asset text NOT NULL DEFAULT 'USDC' CHECK (asset = 'USDC'), + tx_hash text NOT NULL, + status transaction_status NOT NULL DEFAULT 'pending', + metadata jsonb, + idempotency_key text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- 3. Create Constraints and Indexes +ALTER TABLE public.transactions ADD CONSTRAINT transactions_chain_tx_hash_key UNIQUE (chain, tx_hash); +ALTER TABLE public.transactions ADD CONSTRAINT transactions_idempotency_key_key UNIQUE (idempotency_key); +CREATE INDEX idx_transactions_user_id ON public.transactions(user_id); +CREATE INDEX idx_transactions_status ON public.transactions(status); +CREATE INDEX idx_transactions_created_at ON public.transactions(created_at); + +-- 4. Create the `transaction_events` table for audit trail +CREATE TABLE public.transaction_events ( + id bigserial PRIMARY KEY, + transaction_id uuid NOT NULL REFERENCES public.transactions(id) ON DELETE CASCADE, + old_status transaction_status, + new_status transaction_status NOT NULL, + changed_by text NOT NULL DEFAULT 'service_role', + created_at timestamptz NOT NULL DEFAULT now() +); + +-- 5. Create Trigger Functions +CREATE OR REPLACE FUNCTION public.handle_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION public.log_transaction_status_change() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN + INSERT INTO public.transaction_events (transaction_id, old_status, new_status) + VALUES (NEW.id, OLD.status, NEW.status); + ELSIF TG_OP = 'INSERT' THEN + INSERT INTO public.transaction_events (transaction_id, old_status, new_status) + VALUES (NEW.id, NULL, NEW.status); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + +-- 6. Attach Triggers +CREATE TRIGGER on_transactions_update +BEFORE UPDATE ON public.transactions +FOR EACH ROW +EXECUTE FUNCTION public.handle_updated_at(); + +CREATE TRIGGER on_transactions_status_change +AFTER INSERT OR UPDATE ON public.transactions +FOR EACH ROW +EXECUTE FUNCTION public.log_transaction_status_change(); + +-- 7. Enable RLS and Define OPTIMIZED Policies + +-- === TRANSACTIONS TABLE === +ALTER TABLE public.transactions ENABLE ROW LEVEL SECURITY; + +-- Optimized SELECT policy for transactions +CREATE POLICY "Allow read access to owners and service role" +ON public.transactions FOR SELECT TO authenticated, service_role +USING ( + (user_id = (select auth.uid())) OR ((select auth.role()) = 'service_role') +); + +-- Optimized INSERT/UPDATE/DELETE policy for transactions +CREATE POLICY "Allow full modification for service role" +ON public.transactions FOR ALL TO service_role +USING ( (select auth.role()) = 'service_role' ) +WITH CHECK ( (select auth.role()) = 'service_role' ); + +-- === TRANSACTION_EVENTS TABLE === +ALTER TABLE public.transaction_events ENABLE ROW LEVEL SECURITY; + +-- Optimized SELECT policy for transaction_events +CREATE POLICY "Allow read access to event owners and service role" +ON public.transaction_events FOR SELECT TO authenticated, service_role +USING ( + ((select auth.role()) = 'service_role') + OR EXISTS ( + SELECT 1 FROM public.transactions t + WHERE t.id = transaction_id AND t.user_id = (select auth.uid()) + ) +); + +-- Optimized INSERT/UPDATE/DELETE policy for transaction_events +CREATE POLICY "Allow full modification for service role on events" +ON public.transaction_events FOR ALL TO service_role +USING ( (select auth.role()) = 'service_role' ) +WITH CHECK ( (select auth.role()) = 'service_role' ); \ No newline at end of file diff --git a/supabase/migrations/20250926115658_harden_trigger_functions.sql b/supabase/migrations/20250926115658_harden_trigger_functions.sql new file mode 100644 index 0000000..959216c --- /dev/null +++ b/supabase/migrations/20250926115658_harden_trigger_functions.sql @@ -0,0 +1,56 @@ +-- 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 + +-- This migration addresses the "Function Search Path Mutable" security warning +-- by explicitly setting a secure, empty search_path for our trigger functions. +-- This prevents potential privilege escalation attacks by forcing all object +-- references within the functions to be schema-qualified. + +-- Harden the handle_updated_at function +CREATE OR REPLACE FUNCTION public.handle_updated_at() +RETURNS TRIGGER +LANGUAGE plpgsql +-- This is the critical security fix. +-- It isolates the function from the caller's search_path. +SET search_path = '' +AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +-- Harden the log_transaction_status_change function +CREATE OR REPLACE FUNCTION public.log_transaction_status_change() +RETURNS TRIGGER +LANGUAGE plpgsql +-- This is the critical security fix. +-- It isolates the function from the caller's search_path. +SET search_path = '' +AS $$ +BEGIN + -- We must now use fully qualified names because the search_path is empty. + -- e.g., public.transaction_events instead of just transaction_events. + IF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN + INSERT INTO public.transaction_events (transaction_id, old_status, new_status) + VALUES (NEW.id, OLD.status, NEW.status); + ELSIF TG_OP = 'INSERT' THEN + INSERT INTO public.transaction_events (transaction_id, old_status, new_status) + VALUES (NEW.id, NULL, NEW.status); + END IF; + RETURN NEW; +END; +$$; \ No newline at end of file diff --git a/supabase/migrations/20250929103004_create_platform_config_table.sql b/supabase/migrations/20250929103004_create_platform_config_table.sql new file mode 100644 index 0000000..7115781 --- /dev/null +++ b/supabase/migrations/20250929103004_create_platform_config_table.sql @@ -0,0 +1,46 @@ +-- 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 + +-- Migration: Create Platform Configuration Table +-- This table will store key-value pairs for platform-wide settings, +-- such as the ID of the primary Circle merchant wallet. + +CREATE TABLE public.platform_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Enable Row Level Security +ALTER TABLE public.platform_config ENABLE ROW LEVEL SECURITY; + +-- Allow read access for all users (e.g., if you ever need to expose a public key) +CREATE POLICY "Allow public read access" +ON public.platform_config FOR SELECT +USING (true); + +-- Restrict write access to the service role only +CREATE POLICY "Allow full access for service role" +ON public.platform_config FOR ALL +USING (auth.role() = 'service_role') +WITH CHECK (auth.role() = 'service_role'); + +-- Create the trigger for the updated_at timestamp +CREATE TRIGGER on_platform_config_update +BEFORE UPDATE ON public.platform_config +FOR EACH ROW +EXECUTE FUNCTION public.handle_updated_at(); \ No newline at end of file diff --git a/supabase/migrations/20250930142753_create_admin_wallets_table.sql b/supabase/migrations/20250930142753_create_admin_wallets_table.sql new file mode 100644 index 0000000..e5cf70a --- /dev/null +++ b/supabase/migrations/20250930142753_create_admin_wallets_table.sql @@ -0,0 +1,71 @@ +-- 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 + +-- Migration: Drop platform_config and create admin_wallets +-- This migration performs two main actions in a single transaction: +-- 1. It completely removes the now-obsolete `public.platform_config` table. +-- 2. It creates a new, structured `public.admin_wallets` table to store details +-- for platform-controlled administrative wallets. + +-- Step 1: Drop the old `platform_config` table. +-- The `CASCADE` option ensures all dependent objects like RLS policies and triggers are also removed. +DROP TABLE IF EXISTS public.platform_config CASCADE; + + +-- Step 2: Create a custom ENUM type for the wallet status. +-- This ensures data integrity for the `status` column. +CREATE TYPE admin_wallet_status AS ENUM ('ENABLED', 'DISABLED', 'ARCHIVED'); + + +-- Step 3: Create the new `admin_wallets` table. +CREATE TABLE public.admin_wallets ( + id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + circle_wallet_id text NOT NULL UNIQUE, + label text NOT NULL, + status admin_wallet_status NOT NULL DEFAULT 'ENABLED', + chain text DEFAULT NULL, + supported_assets text[] DEFAULT NULL, -- Using a text array for future flexibility + address text NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Add comments for clarity and maintainability +COMMENT ON TABLE public.admin_wallets IS 'Stores details for administrative, platform-controlled wallets.'; +COMMENT ON COLUMN public.admin_wallets.circle_wallet_id IS 'The unique identifier for the wallet from the Circle API.'; +COMMENT ON COLUMN public.admin_wallets.label IS 'A human-readable name for the wallet (e.g., "Primary Merchant Wallet").'; +COMMENT ON COLUMN public.admin_wallets.status IS 'The operational status of the wallet.'; + + +-- Step 4: Enable Row Level Security (RLS) for the new table. +-- This is a critical security step to protect administrative data. +ALTER TABLE public.admin_wallets ENABLE ROW LEVEL SECURITY; + +-- Create a policy that restricts all access to the trusted `service_role` only. +-- Regular users and anonymous users will not be able to read or write to this table. +CREATE POLICY "Allow full access for service role" +ON public.admin_wallets +FOR ALL +USING (auth.role() = 'service_role') +WITH CHECK (auth.role() = 'service_role'); + + +-- Step 5: Create the trigger to automatically update the `updated_at` timestamp. +-- This assumes the `handle_updated_at()` function already exists from a previous migration. +CREATE TRIGGER on_admin_wallets_update +BEFORE UPDATE ON public.admin_wallets +FOR EACH ROW +EXECUTE FUNCTION public.handle_updated_at(); \ No newline at end of file diff --git a/supabase/migrations/20251001134000_circle_webhook_support.sql b/supabase/migrations/20251001134000_circle_webhook_support.sql new file mode 100644 index 0000000..98c769e --- /dev/null +++ b/supabase/migrations/20251001134000_circle_webhook_support.sql @@ -0,0 +1,122 @@ +-- 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 + +-- Migration: Circle Webhook Support +-- Purpose: +-- 1. Add circle_transaction_id to transactions for mapping Circle events +-- 2. Create transaction_webhook_events table to persist raw Circle webhook payloads +-- 3. RLS policies for new table +-- 4. Indexes & constraints for idempotency and fast lookup + +-- ================================ +-- 1. Schema Changes: transactions +-- ================================ + +-- Add nullable circle_transaction_id (some events may arrive before record exists) +ALTER TABLE public.transactions +ADD COLUMN circle_transaction_id text; + +-- Unique index (only one internal transaction per Circle transaction id) +CREATE UNIQUE INDEX IF NOT EXISTS transactions_circle_transaction_id_key +ON public.transactions(circle_transaction_id) +WHERE circle_transaction_id IS NOT NULL; + +-- Optional lookup index (not strictly needed because unique above covers searches) +-- CREATE INDEX IF NOT EXISTS idx_transactions_circle_transaction_id +-- ON public.transactions(circle_transaction_id) +-- WHERE circle_transaction_id IS NOT NULL; + +-- ======================================= +-- 2. New Table: transaction_webhook_events +-- ======================================= +-- Stores every raw Circle webhook notification for audit / replay. +-- We keep circle_transaction_id and circle_event_id (if provided) for idempotency. +-- transaction_id is nullable because a webhook could arrive before app-level +-- transaction creation (we will attempt to backfill linkage in code if found later). + +CREATE TABLE public.transaction_webhook_events ( + id bigserial PRIMARY KEY, + circle_event_id text, -- (Not all Circle webhooks may have a distinct event id; if missing we fallback to hash) + circle_transaction_id text, + transaction_id uuid REFERENCES public.transactions(id) ON DELETE SET NULL, + mapped_status transaction_status, -- Derived internal status (pending/confirmed/failed) if state was mappable + raw_payload jsonb NOT NULL, -- Entire webhook body + signature_valid boolean NOT NULL DEFAULT false, + received_at timestamptz NOT NULL DEFAULT now(), + dedupe_hash text NOT NULL, -- SHA256(body) or other deterministic hash for idempotency when event id missing + UNIQUE (circle_event_id), + UNIQUE (dedupe_hash) +); + +-- Useful indexes +CREATE INDEX idx_twe_circle_transaction_id +ON public.transaction_webhook_events(circle_transaction_id) +WHERE circle_transaction_id IS NOT NULL; + +CREATE INDEX idx_twe_transaction_id +ON public.transaction_webhook_events(transaction_id) +WHERE transaction_id IS NOT NULL; + +CREATE INDEX idx_twe_received_at +ON public.transaction_webhook_events(received_at); + +-- ======================================= +-- 3. RLS Policies +-- ======================================= +ALTER TABLE public.transaction_webhook_events ENABLE ROW LEVEL SECURITY; + +-- Read access: authenticated users can read events tied to their transactions. +-- Service role can read all. +CREATE POLICY "Allow read webhook events for owners and service role" +ON public.transaction_webhook_events +FOR SELECT +TO authenticated, service_role +USING ( + ( (select auth.role()) = 'service_role' ) + OR EXISTS ( + SELECT 1 + FROM public.transactions t + WHERE t.id = transaction_webhook_events.transaction_id + AND t.user_id = (select auth.uid()) + ) +); + +-- Only service role can insert/update/delete +CREATE POLICY "Allow full modification for service role on webhook events" +ON public.transaction_webhook_events +FOR ALL +TO service_role +USING ( (select auth.role()) = 'service_role' ) +WITH CHECK ( (select auth.role()) = 'service_role' ); + +-- ======================================= +-- 4. Notes +-- ======================================= +-- Application logic: +-- - On receiving Circle webhook: +-- * Verify signature +-- * Compute dedupe_hash = sha256(raw JSON string) +-- * Attempt insert into transaction_webhook_events (ignore conflict on dedupe_hash / circle_event_id) +-- * Map Circle state (e.g. PENDING->pending, COMPLETE/CONFIRMED->confirmed, FAILED->failed) +-- * If circle_transaction_id present, locate matching transactions.circle_transaction_id OR (chain, tx_hash) if you store mapping in metadata +-- * Update transactions.status only if changed (triggers will log status change) +-- * After update, optionally backfill transaction_id in transaction_webhook_events row where it was NULL +-- +-- - Idempotency: +-- UNIQUE(circle_event_id) handles normal path; fallback UNIQUE(dedupe_hash) prevents duplicates when event id absent. +-- +-- Rollback considerations: +-- * To revert: drop new table, indexes, column (ensure no dependencies). diff --git a/supabase/migrations/20251002190823_create_admin_transactions_table.sql b/supabase/migrations/20251002190823_create_admin_transactions_table.sql new file mode 100644 index 0000000..0324973 --- /dev/null +++ b/supabase/migrations/20251002190823_create_admin_transactions_table.sql @@ -0,0 +1,56 @@ +-- 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 + +-- This table will store a log of all administrative fund transfers +-- initiated from platform-controlled Circle wallets. + +-- Step 1: Create a custom ENUM type for the transaction status. +CREATE TYPE admin_transaction_status AS ENUM ('PENDING', 'CONFIRMED', 'FAILED'); + +-- Step 2: Create the new `admin_transactions` table. +CREATE TABLE public.admin_transactions ( + id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + circle_transaction_id text NOT NULL UNIQUE, + source_wallet_id uuid NOT NULL REFERENCES public.admin_wallets(id) ON DELETE SET NULL, + destination_address text NOT NULL, + amount numeric(18, 6) NOT NULL, + asset text NOT NULL DEFAULT 'USDC', + chain text NOT NULL, + status admin_transaction_status NOT NULL DEFAULT 'PENDING', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Add comments for clarity +COMMENT ON TABLE public.admin_transactions IS 'Stores a log of administrative fund transfers from Circle wallets.'; +COMMENT ON COLUMN public.admin_transactions.circle_transaction_id IS 'The unique transaction identifier from the Circle API.'; +COMMENT ON COLUMN public.admin_transactions.source_wallet_id IS 'The internal ID of the source admin wallet.'; + +-- Step 3: Enable Row Level Security (RLS). +ALTER TABLE public.admin_transactions ENABLE ROW LEVEL SECURITY; + +-- Create a policy that restricts all access to the trusted `service_role` only. +CREATE POLICY "Allow full access for service role" +ON public.admin_transactions +FOR ALL +USING (auth.role() = 'service_role') +WITH CHECK (auth.role() = 'service_role'); + +-- Step 4: Create the trigger for the `updated_at` timestamp. +CREATE TRIGGER on_admin_transactions_update +BEFORE UPDATE ON public.admin_transactions +FOR EACH ROW +EXECUTE FUNCTION public.handle_updated_at(); \ No newline at end of file diff --git a/supabase/migrations/20251002193120_create_check_user_exists_function.sql b/supabase/migrations/20251002193120_create_check_user_exists_function.sql new file mode 100644 index 0000000..9ef952b --- /dev/null +++ b/supabase/migrations/20251002193120_create_check_user_exists_function.sql @@ -0,0 +1,42 @@ +-- 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 + +-- Migration: Create a function to check for user existence by email. +-- This is a secure and highly performant way to check if a user exists +-- without exposing user data or relying on slow, paginated list methods. + +CREATE OR REPLACE FUNCTION public.check_user_exists(user_email TEXT) +RETURNS BOOLEAN +LANGUAGE plpgsql +-- SECURITY DEFINER is crucial. It allows this function to run with the +-- permissions of its creator (the admin), giving it temporary, secure +-- access to the `auth.users` table. +SECURITY DEFINER SET search_path = public +AS $$ +BEGIN + -- Perform a direct, indexed query on the auth.users table. + -- This is extremely fast and scalable. + RETURN EXISTS ( + SELECT 1 + FROM auth.users + WHERE email = user_email + ); +END; +$$; + +-- Grant execute permission to the service_role so our server-side +-- script can call this function. +GRANT EXECUTE ON FUNCTION public.check_user_exists(TEXT) TO service_role; \ No newline at end of file diff --git a/supabase/migrations/20251003145845_enable_realtime_on_transaction_tables.sql b/supabase/migrations/20251003145845_enable_realtime_on_transaction_tables.sql new file mode 100644 index 0000000..cd6d316 --- /dev/null +++ b/supabase/migrations/20251003145845_enable_realtime_on_transaction_tables.sql @@ -0,0 +1,37 @@ +-- 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 + +-- Migration: Enable Realtime on Transaction Tables +-- This script enables the Supabase Realtime feature for the `admin_transactions`, +-- `transactions`, and `transaction_events` tables. This will allow clients +-- to subscribe to database changes (INSERT, UPDATE, DELETE) on these tables. + +-- Step 1: Add the tables to the `supabase_realtime` publication. +-- This tells PostgreSQL to send changes from these tables to the Realtime broadcasting service. + +ALTER PUBLICATION supabase_realtime ADD TABLE public.admin_transactions; +ALTER PUBLICATION supabase_realtime ADD TABLE public.transactions; +ALTER PUBLICATION supabase_realtime ADD TABLE public.transaction_events; + + +-- Step 2: Set the REPLICA IDENTITY for each table to FULL. +-- This ensures that when an UPDATE or DELETE event occurs, the broadcasted +-- message contains the complete data of the old row, which is essential for +-- building responsive, real-time user interfaces. + +ALTER TABLE public.admin_transactions REPLICA IDENTITY FULL; +ALTER TABLE public.transactions REPLICA IDENTITY FULL; +ALTER TABLE public.transaction_events REPLICA IDENTITY FULL; \ No newline at end of file diff --git a/supabase/migrations/20251009164322_fix_admin_transaction_rls.sql b/supabase/migrations/20251009164322_fix_admin_transaction_rls.sql new file mode 100644 index 0000000..6285beb --- /dev/null +++ b/supabase/migrations/20251009164322_fix_admin_transaction_rls.sql @@ -0,0 +1,33 @@ +-- 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 + +-- Drop the old, brittle policy that was causing the silent failure. +DROP POLICY IF EXISTS "Allow full access for service role" ON public.admin_transactions; + +-- Create a new, robust policy for the service_role. +-- This policy states that if the user's role is 'service_role', they can +-- perform ALL actions on ANY rows. This is the standard way to grant +-- full backend access while keeping RLS enabled for other potential roles. +CREATE POLICY "Allow full access to service role" +ON public.admin_transactions +FOR ALL +TO service_role -- This is a shorthand and cleaner way to specify the target role +USING (true) +WITH CHECK (true); + +-- IMPORTANT: Ensure RLS is still enabled on the table. +-- This command will do nothing if it's already enabled, but it's good practice to be explicit. +ALTER TABLE public.admin_transactions ENABLE ROW LEVEL SECURITY; \ No newline at end of file diff --git a/supabase/migrations/20251009174425_add_admin_transaction_type.sql b/supabase/migrations/20251009174425_add_admin_transaction_type.sql new file mode 100644 index 0000000..00d0c4f --- /dev/null +++ b/supabase/migrations/20251009174425_add_admin_transaction_type.sql @@ -0,0 +1,38 @@ +-- 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 + +-- This migration introduces a new ENUM type to classify transactions +-- and adds a 'type' column to the admin_transactions table to use it. + +-- Step 1: Create the new ENUM type for the transaction type. +-- This defines the allowed values for the new 'type' column. +CREATE TYPE public.admin_transaction_type AS ENUM ( + 'STANDARD', + 'CCTP_APPROVAL', + 'CCTP_BURN', + 'CCTP_MINT' +); + +-- Add a comment for clarity on the new type. +COMMENT ON TYPE public.admin_transaction_type IS 'Defines the types of administrative transactions, distinguishing between standard transfers and multi-step CCTP operations.'; + + +-- Step 2: Alter the existing `admin_transactions` table to add the new column. +ALTER TABLE public.admin_transactions +ADD COLUMN type public.admin_transaction_type NOT NULL DEFAULT 'STANDARD'; + +-- Add a comment to the new column for future reference. +COMMENT ON COLUMN public.admin_transactions.type IS 'The type of the transaction (e.g., STANDARD, CCTP_APPROVAL, CCTP_BURN, CCTP_MINT).'; \ No newline at end of file diff --git a/supabase/migrations/20251016155224_enable_realtime_updates_on_admin_transactions.sql b/supabase/migrations/20251016155224_enable_realtime_updates_on_admin_transactions.sql new file mode 100644 index 0000000..ebabc55 --- /dev/null +++ b/supabase/migrations/20251016155224_enable_realtime_updates_on_admin_transactions.sql @@ -0,0 +1,35 @@ +-- 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 + +-- This script idempotently ensures that the 'admin_transactions' table +-- is configured to broadcast INSERT, UPDATE, and DELETE events via Realtime. + +DO $$ +BEGIN + -- First, check if the table is already a member of the publication. + IF EXISTS ( + SELECT 1 + FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' AND tablename = 'admin_transactions' + ) THEN + -- If it is, remove it. This is necessary to reset its publication properties. + ALTER PUBLICATION supabase_realtime DROP TABLE public.admin_transactions; + END IF; +END $$; + +-- Now, add the table back to the publication. This applies the publication's +-- default rules, which include broadcasting INSERT, UPDATE, and DELETE events. +ALTER PUBLICATION supabase_realtime ADD TABLE public.admin_transactions; \ No newline at end of file diff --git a/supabase/migrations/20251027194506_create_transaction_history_table.sql b/supabase/migrations/20251016165811_add_rls_to_admin_transactions.sql similarity index 57% rename from supabase/migrations/20251027194506_create_transaction_history_table.sql rename to supabase/migrations/20251016165811_add_rls_to_admin_transactions.sql index 804cb01..d0bb326 100644 --- a/supabase/migrations/20251027194506_create_transaction_history_table.sql +++ b/supabase/migrations/20251016165811_add_rls_to_admin_transactions.sql @@ -1,4 +1,4 @@ --- Copyright 2026 Circle Internet Group, Inc. All rights reserved. +-- 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. @@ -14,16 +14,12 @@ -- -- SPDX-License-Identifier: Apache-2.0 --- Supabase Transaction History Table Schema +-- Enable Row Level Security on the table if it's not already. +ALTER TABLE public.admin_transactions ENABLE ROW LEVEL SECURITY; -CREATE TABLE transaction_history ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - user_id uuid NOT NULL, - chain text NOT NULL, - tx_type text NOT NULL, -- 'deposit', 'transfer', or 'unify' - amount numeric NOT NULL, - tx_hash text, - gateway_wallet_address text, - destination_chain text, - created_at timestamptz DEFAULT now() -); +-- Create a policy that allows any logged-in user to read the transactions. +CREATE POLICY "Allow authenticated users to read admin transactions" +ON public.admin_transactions +FOR SELECT +TO authenticated +USING (true); \ No newline at end of file diff --git a/supabase/migrations/20251113000000_add_status_to_transactions.sql b/supabase/migrations/20251016171746_add_completed_status_to_admin_transactions.sql similarity index 54% rename from supabase/migrations/20251113000000_add_status_to_transactions.sql rename to supabase/migrations/20251016171746_add_completed_status_to_admin_transactions.sql index 11101a7..675466f 100644 --- a/supabase/migrations/20251113000000_add_status_to_transactions.sql +++ b/supabase/migrations/20251016171746_add_completed_status_to_admin_transactions.sql @@ -1,4 +1,4 @@ --- Copyright 2026 Circle Internet Group, Inc. All rights reserved. +-- 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. @@ -14,12 +14,5 @@ -- -- SPDX-License-Identifier: Apache-2.0 --- Add status and reason columns to transaction_history table - -ALTER TABLE transaction_history -ADD COLUMN status text DEFAULT 'success' CHECK (status IN ('pending', 'success', 'failed')), -ADD COLUMN reason text; - --- Add index for better query performance -CREATE INDEX idx_transaction_history_user_status ON transaction_history(user_id, status); -CREATE INDEX idx_transaction_history_created_at ON transaction_history(created_at DESC); +-- Add 'COMPLETED' to the admin_transaction_status enum if it doesn't already exist. +ALTER TYPE public.admin_transaction_status ADD VALUE IF NOT EXISTS 'COMPLETED'; \ No newline at end of file diff --git a/supabase/migrations/20251016181326_enforce_single_cctp_mint.sql b/supabase/migrations/20251016181326_enforce_single_cctp_mint.sql new file mode 100644 index 0000000..c1d6a65 --- /dev/null +++ b/supabase/migrations/20251016181326_enforce_single_cctp_mint.sql @@ -0,0 +1,24 @@ +-- 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 + +-- Add a column to link a mint transaction back to its parent burn transaction. +ALTER TABLE public.admin_transactions + ADD COLUMN cctp_burn_tx_id UUID REFERENCES public.admin_transactions(id) ON DELETE SET NULL; + +-- Create a unique index on this new column. +-- This is the core of the fix: it makes it impossible for more than one row +-- to ever reference the same parent burn transaction. +CREATE UNIQUE INDEX one_mint_per_burn_idx ON public.admin_transactions (cctp_burn_tx_id); \ No newline at end of file diff --git a/supabase/migrations/20251016184938_rename_completed_status_to_complete.sql b/supabase/migrations/20251016184938_rename_completed_status_to_complete.sql new file mode 100644 index 0000000..09b14f9 --- /dev/null +++ b/supabase/migrations/20251016184938_rename_completed_status_to_complete.sql @@ -0,0 +1,33 @@ +-- 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 + +-- This migration renames the 'COMPLETED' value to 'COMPLETE' in the +-- 'admin_transaction_status' enum type to align with Circle's v2 API states. + +DO $$ +BEGIN + -- First, check if the 'COMPLETED' value actually exists in the enum. + -- This makes the migration safe to re-run without causing an error. + IF EXISTS ( + SELECT 1 + FROM pg_enum + WHERE enumlabel = 'COMPLETED' + AND enumtypid = 'public.admin_transaction_status'::regtype + ) THEN + -- If it exists, execute the rename command. + ALTER TYPE public.admin_transaction_status RENAME VALUE 'COMPLETED' TO 'COMPLETE'; + END IF; +END $$; \ No newline at end of file diff --git a/supabase/migrations/20251106165045_create_wallets_table.sql b/supabase/migrations/20251106165045_create_wallets_table.sql deleted file mode 100644 index 260cd00..0000000 --- a/supabase/migrations/20251106165045_create_wallets_table.sql +++ /dev/null @@ -1,46 +0,0 @@ --- Copyright 2026 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 - --- Create the wallets table with a UUID primary key -CREATE TABLE public.wallets ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - circle_wallet_id TEXT NOT NULL UNIQUE, - wallet_set_id TEXT NOT NULL, - wallet_address TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Add comments for clarity -COMMENT ON TABLE public.wallets IS 'Stores Circle developer wallets created by users.'; -COMMENT ON COLUMN public.wallets.id IS 'The unique identifier for the wallet record.'; -COMMENT ON COLUMN public.wallets.user_id IS 'Foreign key to the authenticated user in auth.users.'; -COMMENT ON COLUMN public.wallets.circle_wallet_id IS 'The unique ID of the wallet from Circle.'; - --- 1. Enable Row Level Security (RLS) on the table -ALTER TABLE public.wallets ENABLE ROW LEVEL SECURITY; - --- 2. Create a policy that allows users to read their own wallets -CREATE POLICY "Allow authenticated users to read their own wallets" -ON public.wallets -FOR SELECT -USING (auth.uid() = user_id); - --- 3. Create a policy that allows users to insert a wallet for themselves -CREATE POLICY "Allow authenticated users to create their own wallet" -ON public.wallets -FOR INSERT -WITH CHECK (auth.uid() = user_id); \ No newline at end of file diff --git a/supabase/migrations/20251112142141_create_credits_table.sql b/supabase/migrations/20251112142141_create_credits_table.sql new file mode 100644 index 0000000..a21d2c0 --- /dev/null +++ b/supabase/migrations/20251112142141_create_credits_table.sql @@ -0,0 +1,97 @@ +-- 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 + +-- Create the 'credits' table +CREATE TABLE public.credits ( + id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + user_id uuid NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE, + credits numeric(18, 6) NOT NULL DEFAULT 0 CHECK (credits >= 0) +); +COMMENT ON TABLE public.credits IS 'Stores the credit balance for each user.'; + + +-- Create a trigger function to handle new user creation (with admin exception) +CREATE OR REPLACE FUNCTION public.handle_new_user_credits() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER SET search_path = public +AS $$ +BEGIN + -- Only create a credits row if the new user's email is NOT the admin's. + IF new.email <> 'admin@admin.com' THEN + INSERT INTO public.credits (user_id) + VALUES (new.id); + END IF; + RETURN new; +END; +$$; +COMMENT ON FUNCTION public.handle_new_user_credits() IS 'Creates a credits row for a new user, unless they are the admin user.'; + + +-- Create the trigger on the 'auth.users' table +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user_credits(); + + +-- Create the RPC function to atomically increment credits (with upsert and admin exception) +CREATE OR REPLACE FUNCTION public.increment_credits( + user_id_to_update uuid, + amount_to_add numeric +) +RETURNS numeric +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, auth +AS $$ +DECLARE + new_credits_balance numeric; + user_email text; +BEGIN + SELECT email INTO user_email FROM users WHERE id = user_id_to_update; + IF user_email = 'admin@admin.com' THEN + RETURN 0; + ELSE + INSERT INTO public.credits (user_id, credits) + VALUES (user_id_to_update, amount_to_add) + ON CONFLICT (user_id) + DO UPDATE SET + credits = credits.credits + amount_to_add + RETURNING credits INTO new_credits_balance; + RETURN new_credits_balance; + END IF; +END; +$$; +COMMENT ON FUNCTION public.increment_credits(uuid, numeric) IS 'Atomically increments credits for a user. Creates a record if none exists. Returns 0 and does nothing for the admin user (admin@admin.com).'; + + +-- Set up Row-Level Security (RLS) and Permissions +ALTER TABLE public.credits ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view their own credits" +ON public.credits FOR SELECT +USING ( auth.uid() = user_id ); + +CREATE POLICY "Users can insert their own credit record" +ON public.credits FOR INSERT +WITH CHECK ( auth.uid() = user_id ); + +GRANT EXECUTE ON FUNCTION public.increment_credits(uuid, numeric) TO service_role; + + +-- Enable Supabase Realtime on the 'credits' table +-- This adds the table to the 'supabase_realtime' publication. +ALTER PUBLICATION supabase_realtime ADD TABLE public.credits; \ No newline at end of file diff --git a/supabase/migrations/20251114000000_add_confirming_status.sql b/supabase/migrations/20251114000000_add_confirming_status.sql new file mode 100644 index 0000000..e04e842 --- /dev/null +++ b/supabase/migrations/20251114000000_add_confirming_status.sql @@ -0,0 +1,24 @@ +-- 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 + +-- Add 'completed' status to transaction_status enum +-- This status indicates the transaction has been confirmed on-chain by MetaMask +-- but hasn't been processed by Circle's webhook yet + +ALTER TYPE transaction_status ADD VALUE IF NOT EXISTS 'completed' BEFORE 'confirmed'; + +-- Add comment explaining the new status +COMMENT ON TYPE transaction_status IS 'Transaction status: pending (submitted), completed (on-chain confirmed by wallet), confirmed (verified by Circle), complete (fully processed), failed (transaction failed)'; diff --git a/supabase/migrations/20251114100000_unify_transaction_tables.sql b/supabase/migrations/20251114100000_unify_transaction_tables.sql new file mode 100644 index 0000000..28f3bba --- /dev/null +++ b/supabase/migrations/20251114100000_unify_transaction_tables.sql @@ -0,0 +1,152 @@ +-- 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 + +-- Migration: Unify transactions and admin_transactions tables +-- This consolidates both user credit purchases and admin transfers into a single table +-- with a transaction_type column to differentiate between transaction types. + +-- Step 1: Create transaction_type enum +CREATE TYPE public.transaction_type AS ENUM ( + 'USER', -- Regular user credit purchase + 'ADMIN', -- Admin standard transfer (same chain) + 'CCTP_APPROVAL', -- CCTP step 1: Approval + 'CCTP_BURN', -- CCTP step 2: Burn + 'CCTP_MINT' -- CCTP step 3: Mint (via OpenZeppelin relayer) +); + +COMMENT ON TYPE public.transaction_type IS 'Classifies transactions by type: USER (credit purchases), ADMIN (standard transfers), CCTP_* (cross-chain transfer steps)'; + +-- Step 2: Add new columns to transactions table +-- Note: circle_transaction_id already exists from migration 20251001134000 +ALTER TABLE public.transactions + ADD COLUMN IF NOT EXISTS transaction_type public.transaction_type NOT NULL DEFAULT 'USER', + ADD COLUMN IF NOT EXISTS source_wallet_id uuid REFERENCES public.admin_wallets(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS destination_address text; + +-- Step 3: Make user-specific columns nullable (for admin transactions) +ALTER TABLE public.transactions + ALTER COLUMN user_id DROP NOT NULL, + ALTER COLUMN credit_amount DROP NOT NULL, + ALTER COLUMN exchange_rate DROP NOT NULL, + ALTER COLUMN direction DROP NOT NULL; + +-- Step 4: Make tx_hash nullable (admin CCTP transactions may not have immediate tx_hash) +ALTER TABLE public.transactions + ALTER COLUMN tx_hash DROP NOT NULL; + +-- Step 5: Add indexes for new columns +CREATE INDEX IF NOT EXISTS idx_transactions_transaction_type ON public.transactions(transaction_type); +-- Circle transaction ID index already exists from previous migration +-- CREATE INDEX IF NOT EXISTS idx_transactions_circle_transaction_id ON public.transactions(circle_transaction_id) WHERE circle_transaction_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_transactions_source_wallet_id ON public.transactions(source_wallet_id) WHERE source_wallet_id IS NOT NULL; + +-- Step 6: Unique constraint for circle_transaction_id already exists from migration 20251001134000 +-- CREATE UNIQUE INDEX IF NOT EXISTS transactions_circle_transaction_id_key ON public.transactions(circle_transaction_id) WHERE circle_transaction_id IS NOT NULL; + +-- Step 7: Add check constraints to ensure data integrity +ALTER TABLE public.transactions + ADD CONSTRAINT check_user_transaction_fields + CHECK ( + (transaction_type = 'USER' AND user_id IS NOT NULL AND credit_amount IS NOT NULL AND exchange_rate IS NOT NULL AND direction IS NOT NULL) + OR (transaction_type != 'USER') + ), + ADD CONSTRAINT check_admin_transaction_fields + CHECK ( + (transaction_type IN ('ADMIN', 'CCTP_APPROVAL', 'CCTP_BURN', 'CCTP_MINT') AND circle_transaction_id IS NOT NULL AND destination_address IS NOT NULL) + OR (transaction_type = 'USER') + ); + +-- Step 8: Migrate data from admin_transactions to transactions +INSERT INTO public.transactions ( + id, + transaction_type, + circle_transaction_id, + source_wallet_id, + destination_address, + amount_usdc, + asset, + chain, + status, + created_at, + updated_at, + wallet_id, + -- Set defaults for required user fields (they won't be used for admin transactions) + idempotency_key +) +SELECT + id, + -- Map admin_transaction_type to transaction_type + CASE + WHEN type = 'STANDARD' THEN 'ADMIN'::transaction_type + WHEN type = 'CCTP_APPROVAL' THEN 'CCTP_APPROVAL'::transaction_type + WHEN type = 'CCTP_BURN' THEN 'CCTP_BURN'::transaction_type + WHEN type = 'CCTP_MINT' THEN 'CCTP_MINT'::transaction_type + END, + circle_transaction_id, + source_wallet_id, + destination_address, + amount, + asset, + chain, + -- Map admin_transaction_status to transaction_status + CASE + WHEN status = 'PENDING' THEN 'pending'::transaction_status + WHEN status = 'CONFIRMED' THEN 'confirmed'::transaction_status + WHEN status = 'FAILED' THEN 'failed'::transaction_status + END, + created_at, + updated_at, + -- Use destination_address as wallet_id for admin transactions (for compatibility) + destination_address, + -- Generate idempotency_key from circle_transaction_id + 'admin:' || circle_transaction_id +FROM public.admin_transactions +ON CONFLICT (idempotency_key) DO NOTHING; + +-- Step 9: Update RLS policies to handle both user and admin transactions +-- Drop existing policies +DROP POLICY IF EXISTS "Allow read access to owners and service role" ON public.transactions; +DROP POLICY IF EXISTS "Allow full modification for service role" ON public.transactions; + +-- Create new policies +-- Users can read their own USER transactions +CREATE POLICY "Users can read their own transactions" +ON public.transactions FOR SELECT TO authenticated +USING ( + (transaction_type = 'USER' AND user_id = auth.uid()) +); + +-- Service role can read all transactions +CREATE POLICY "Service role can read all transactions" +ON public.transactions FOR SELECT TO service_role +USING (true); + +-- Service role can perform all operations +CREATE POLICY "Service role can modify all transactions" +ON public.transactions FOR ALL TO service_role +USING (true) +WITH CHECK (true); + +-- Step 10: Add comments for documentation +COMMENT ON COLUMN public.transactions.transaction_type IS 'Type of transaction: USER (credit purchase), ADMIN (standard transfer), CCTP_* (cross-chain steps)'; +COMMENT ON COLUMN public.transactions.circle_transaction_id IS 'Circle API transaction ID (for admin transactions)'; +COMMENT ON COLUMN public.transactions.source_wallet_id IS 'Source admin wallet (for admin transactions)'; +COMMENT ON COLUMN public.transactions.destination_address IS 'Destination address (for admin transactions)'; + +-- Note: We keep admin_transactions table for now as a backup. +-- In a future migration, we can drop it after confirming everything works. +-- For now, we'll just disable realtime updates on it: +-- ALTER PUBLICATION supabase_realtime DROP TABLE public.admin_transactions; diff --git a/supabase/migrations/20251114120000_backfill_user_transaction_destination.sql b/supabase/migrations/20251114120000_backfill_user_transaction_destination.sql new file mode 100644 index 0000000..d0a7167 --- /dev/null +++ b/supabase/migrations/20251114120000_backfill_user_transaction_destination.sql @@ -0,0 +1,33 @@ +-- 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 + +-- Migration: Backfill destination_address for existing USER transactions +-- All USER transactions are sent to the oldest admin wallet (as per /api/destination-wallet logic) + +-- Update all USER transactions that don't have a destination_address set +-- Set it to the address of the oldest admin wallet +UPDATE public.transactions +SET destination_address = ( + SELECT address + FROM public.admin_wallets + ORDER BY created_at ASC + LIMIT 1 +) +WHERE transaction_type = 'USER' + AND destination_address IS NULL; + +-- Add a comment explaining this backfill +COMMENT ON COLUMN public.transactions.destination_address IS 'Destination address (for admin transactions and user top-ups). USER transactions without this field were backfilled to use the oldest admin wallet.'; diff --git a/supabase/migrations/20251124000000_fix_admin_transaction_rls_policy.sql b/supabase/migrations/20251124000000_fix_admin_transaction_rls_policy.sql new file mode 100644 index 0000000..6f67d9c --- /dev/null +++ b/supabase/migrations/20251124000000_fix_admin_transaction_rls_policy.sql @@ -0,0 +1,25 @@ +-- 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 + +-- Migration: Fix RLS policy to allow authenticated users to read all transactions +-- This allows the admin dashboard to receive realtime updates for admin transactions +-- Note: Since only admin users can log in (enforced at application level), +-- it's safe to allow all authenticated users to read all transactions + +-- Create a policy that allows any authenticated user to read all transactions +CREATE POLICY "Authenticated users can read all transactions" +ON public.transactions FOR SELECT TO authenticated +USING (true); diff --git a/supabase/migrations/20251124000001_add_unique_constraint_admin_wallet_label.sql b/supabase/migrations/20251124000001_add_unique_constraint_admin_wallet_label.sql new file mode 100644 index 0000000..fc3d030 --- /dev/null +++ b/supabase/migrations/20251124000001_add_unique_constraint_admin_wallet_label.sql @@ -0,0 +1,25 @@ +-- 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 + +-- Migration: Add unique constraint to admin_wallets label column +-- This prevents duplicate admin wallets from being created during initialization race conditions + +-- Add a unique constraint on the label column to prevent duplicate "Primary wallet" entries +CREATE UNIQUE INDEX IF NOT EXISTS idx_admin_wallets_label_unique +ON public.admin_wallets(label) +WHERE label = 'Primary wallet'; + +COMMENT ON INDEX idx_admin_wallets_label_unique IS 'Ensures only one "Primary wallet" can exist, preventing race condition duplicates during initialization'; diff --git a/supabase/migrations/20251124000002_clean_up_duplicate_rls_policies.sql b/supabase/migrations/20251124000002_clean_up_duplicate_rls_policies.sql new file mode 100644 index 0000000..24f34fe --- /dev/null +++ b/supabase/migrations/20251124000002_clean_up_duplicate_rls_policies.sql @@ -0,0 +1,26 @@ +-- 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 + +-- Migration: Clean up duplicate RLS policies on transactions table +-- The previous migration added "Authenticated users can read all transactions" +-- but didn't remove the older "Users can read their own transactions" policy +-- This causes confusion and potential issues with realtime subscriptions + +-- Drop the old restrictive policy +DROP POLICY IF EXISTS "Users can read their own transactions" ON public.transactions; + +-- Keep the broader policy that allows all authenticated users to read all transactions +-- (This is safe because only admin users can authenticate in this application) diff --git a/supabase/migrations/20251124000003_remove_completed_status.sql b/supabase/migrations/20251124000003_remove_completed_status.sql new file mode 100644 index 0000000..26c37aa --- /dev/null +++ b/supabase/migrations/20251124000003_remove_completed_status.sql @@ -0,0 +1,60 @@ +-- 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 + +-- Migration: Remove 'completed' status and standardize on 'complete' +-- This migration removes the 'completed' status from the transaction_status enum +-- and updates any existing transactions using 'completed' to use 'complete' instead + +-- Step 1: Update any existing transactions with 'completed' status to 'complete' +UPDATE public.transactions +SET status = 'complete' +WHERE status = 'completed'; + +-- Step 2: Remove 'completed' from the transaction_status enum +-- We need to create a new enum without 'completed', then swap it in +CREATE TYPE transaction_status_new AS ENUM ('pending', 'confirmed', 'complete', 'failed'); + +-- Step 3: Drop the default constraint temporarily +ALTER TABLE public.transactions + ALTER COLUMN status DROP DEFAULT; + +-- Step 4: Update all tables using the transaction_status enum +ALTER TABLE public.transactions + ALTER COLUMN status TYPE transaction_status_new + USING status::text::transaction_status_new; + +ALTER TABLE public.transaction_events + ALTER COLUMN new_status TYPE transaction_status_new + USING new_status::text::transaction_status_new; + +ALTER TABLE public.transaction_events + ALTER COLUMN old_status TYPE transaction_status_new + USING old_status::text::transaction_status_new; + +ALTER TABLE public.transaction_webhook_events + ALTER COLUMN mapped_status TYPE transaction_status_new + USING mapped_status::text::transaction_status_new; + +-- Step 5: Restore the default value for transactions table +ALTER TABLE public.transactions + ALTER COLUMN status SET DEFAULT 'pending'::transaction_status_new; + +-- Step 6: Drop the old enum and rename the new one +DROP TYPE transaction_status CASCADE; +ALTER TYPE transaction_status_new RENAME TO transaction_status; + +-- Add a comment to document the change +COMMENT ON TYPE transaction_status IS 'Transaction status enum: pending (initial), confirmed (Circle confirmed), complete (on-chain confirmed), failed'; diff --git a/supabase/migrations/20251124000004_drop_admin_transactions_table.sql b/supabase/migrations/20251124000004_drop_admin_transactions_table.sql new file mode 100644 index 0000000..cc9c0c1 --- /dev/null +++ b/supabase/migrations/20251124000004_drop_admin_transactions_table.sql @@ -0,0 +1,26 @@ +-- 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 + +-- Migration: Drop the deprecated admin_transactions table +-- The admin_transactions table has been replaced by the unified transactions table +-- with transaction_type column to distinguish between different transaction types +-- This was done in migration 20251114100000_unify_transaction_tables.sql + +-- Drop the admin_transactions table +DROP TABLE IF EXISTS public.admin_transactions CASCADE; + +-- Add a comment to document why it was removed +COMMENT ON TABLE public.transactions IS 'Unified transactions table containing USER (credit purchases), ADMIN (standard transfers), and CCTP_* (cross-chain transfer steps) transactions'; diff --git a/supabase/migrations/20251124010000_fix_transaction_inconsistencies.sql b/supabase/migrations/20251124010000_fix_transaction_inconsistencies.sql new file mode 100644 index 0000000..5e23bd1 --- /dev/null +++ b/supabase/migrations/20251124010000_fix_transaction_inconsistencies.sql @@ -0,0 +1,48 @@ +-- 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 + +-- Migration to fix transaction table inconsistencies +-- This migration addresses: +-- 1. Standardizes chain format to use numeric chain IDs instead of string names +-- 2. Fixes wallet_id semantics for ADMIN transactions (should be source, not destination) +-- 3. Updates existing data to match the corrected schema + +-- First, update existing ADMIN transactions to use numeric chain IDs +-- ARC-TESTNET should be 5042002 +UPDATE transactions +SET chain = '5042002' +WHERE chain = 'ARC-TESTNET' AND transaction_type IN ('ADMIN', 'CCTP_APPROVAL', 'CCTP_BURN', 'CCTP_MINT'); + +-- Fix wallet_id for ADMIN transactions: it should represent the source wallet address, not destination +-- For ADMIN transactions, wallet_id currently holds the destination address +-- We need to look up the actual source wallet address and swap them +UPDATE transactions t +SET + wallet_id = aw.address, + -- destination_address already has the correct value + metadata = jsonb_set( + COALESCE(t.metadata, '{}'::jsonb), + '{migration_note}', + '"Fixed wallet_id to represent source wallet address"'::jsonb + ) +FROM admin_wallets aw +WHERE t.transaction_type IN ('ADMIN', 'CCTP_APPROVAL', 'CCTP_BURN', 'CCTP_MINT') + AND t.source_wallet_id = aw.id + AND t.wallet_id != aw.address; + +-- Add a comment to clarify the wallet_id column semantics +COMMENT ON COLUMN transactions.wallet_id IS 'For USER transactions: user wallet address that sent funds. For ADMIN transactions: source admin wallet address.'; +COMMENT ON COLUMN transactions.destination_address IS 'For USER transactions: admin wallet that received funds. For ADMIN transactions: destination address receiving funds.'; diff --git a/supabase/migrations/20251124010001_add_tx_hash_validation.sql b/supabase/migrations/20251124010001_add_tx_hash_validation.sql new file mode 100644 index 0000000..7a2d3d9 --- /dev/null +++ b/supabase/migrations/20251124010001_add_tx_hash_validation.sql @@ -0,0 +1,31 @@ +-- 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 + +-- Migration to add validation for tx_hash on completed transactions +-- Note: We don't enforce NOT NULL because tx_hash may not be available immediately +-- when a transaction is created. However, we add a helpful constraint to remind +-- developers that completed/confirmed transactions should have a tx_hash. + +-- Add a check constraint that warns if a transaction is complete/confirmed without a tx_hash +-- This is implemented as a comment-only approach since Circle webhooks may not always +-- provide tx_hash immediately, and we don't want to block status updates. + +COMMENT ON COLUMN transactions.tx_hash IS 'Transaction hash on blockchain. Should be populated for all complete/confirmed transactions.'; + +-- Create an index to help identify transactions that are complete but missing tx_hash +CREATE INDEX IF NOT EXISTS idx_transactions_missing_tx_hash +ON transactions(status) +WHERE tx_hash IS NULL AND status IN ('complete', 'confirmed'); diff --git a/supabase/migrations/20251201205400_remove_admin_transactions_from_realtime.sql b/supabase/migrations/20251201205400_remove_admin_transactions_from_realtime.sql new file mode 100644 index 0000000..ef4a3bd --- /dev/null +++ b/supabase/migrations/20251201205400_remove_admin_transactions_from_realtime.sql @@ -0,0 +1,29 @@ +-- 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 + +-- Migration: Remove admin_transactions from supabase_realtime publication (table no longer exists) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND tablename = 'admin_transactions' + AND schemaname = 'public' + ) THEN + ALTER PUBLICATION supabase_realtime DROP TABLE public.admin_transactions; + END IF; +END $$; diff --git a/types/admin-transaction.ts b/types/admin-transaction.ts new file mode 100644 index 0000000..e7155db --- /dev/null +++ b/types/admin-transaction.ts @@ -0,0 +1,33 @@ +/** + * 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 type for admin transactions (includes USER transactions sent to admin wallets) +export type AdminTransaction = { + id: string; + circle_transaction_id: string | null; + destination_address: string; + amount_usdc: number; + amount?: number; // For backward compatibility + asset: string; + chain: string; + status: "PENDING" | "CONFIRMED" | "FAILED" | "COMPLETE" | "pending" | "confirmed" | "complete" | "failed"; + created_at: string; + source_wallet: { label: string } | null; + transaction_type: "CCTP_APPROVAL" | "CCTP_BURN" | "CCTP_MINT" | "ADMIN" | "USER"; + wallet_id?: string | null; // For USER transactions +}; diff --git a/types/environment.d.ts b/types/environment.d.ts index 6b3ddac..0b5a769 100644 --- a/types/environment.d.ts +++ b/types/environment.d.ts @@ -1,5 +1,5 @@ /** - * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * 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. @@ -20,10 +20,16 @@ namespace NodeJS { interface ProcessEnv { // Supabase NEXT_PUBLIC_SUPABASE_URL: string - NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: string + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY: string + SUPABASE_SERVICE_ROLE_KEY: string // Circle CIRCLE_API_KEY: string CIRCLE_ENTITY_SECRET: string + CIRCLE_BLOCKCHAIN: string + CIRCLE_USDC_TOKEN_ID: string + + // Misc + ADMIN_EMAIL: string } } diff --git a/types/supabase.ts b/types/supabase.ts new file mode 100644 index 0000000..c4f730d --- /dev/null +++ b/types/supabase.ts @@ -0,0 +1,457 @@ +/** + * 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 + */ + +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + extensions?: Json + operationName?: string + query?: string + variables?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } + public: { + Tables: { + admin_transactions: { + Row: { + amount: number + asset: string + chain: string + circle_transaction_id: string + created_at: string + destination_address: string + id: string + source_wallet_id: string + status: Database["public"]["Enums"]["admin_transaction_status"] + type: Database["public"]["Enums"]["admin_transaction_type"] + updated_at: string + } + Insert: { + amount: number + asset?: string + chain: string + circle_transaction_id: string + created_at?: string + destination_address: string + id?: string + source_wallet_id: string + status?: Database["public"]["Enums"]["admin_transaction_status"] + type?: Database["public"]["Enums"]["admin_transaction_type"] + updated_at?: string + } + Update: { + amount?: number + asset?: string + chain?: string + circle_transaction_id?: string + created_at?: string + destination_address?: string + id?: string + source_wallet_id?: string + status?: Database["public"]["Enums"]["admin_transaction_status"] + type?: Database["public"]["Enums"]["admin_transaction_type"] + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "admin_transactions_source_wallet_id_fkey" + columns: ["source_wallet_id"] + isOneToOne: false + referencedRelation: "admin_wallets" + referencedColumns: ["id"] + }, + ] + } + admin_wallets: { + Row: { + address: string + chain: string | null + circle_wallet_id: string + created_at: string + id: string + label: string + status: Database["public"]["Enums"]["admin_wallet_status"] + supported_assets: string[] | null + updated_at: string + } + Insert: { + address: string + chain?: string | null + circle_wallet_id: string + created_at?: string + id?: string + label: string + status?: Database["public"]["Enums"]["admin_wallet_status"] + supported_assets?: string[] | null + updated_at?: string + } + Update: { + address?: string + chain?: string | null + circle_wallet_id?: string + created_at?: string + id?: string + label?: string + status?: Database["public"]["Enums"]["admin_wallet_status"] + supported_assets?: string[] | null + updated_at?: string + } + Relationships: [] + } + transaction_events: { + Row: { + changed_by: string + created_at: string + id: number + new_status: Database["public"]["Enums"]["transaction_status"] + old_status: Database["public"]["Enums"]["transaction_status"] | null + transaction_id: string + } + Insert: { + changed_by?: string + created_at?: string + id?: number + new_status: Database["public"]["Enums"]["transaction_status"] + old_status?: Database["public"]["Enums"]["transaction_status"] | null + transaction_id: string + } + Update: { + changed_by?: string + created_at?: string + id?: number + new_status?: Database["public"]["Enums"]["transaction_status"] + old_status?: Database["public"]["Enums"]["transaction_status"] | null + transaction_id?: string + } + Relationships: [ + { + foreignKeyName: "transaction_events_transaction_id_fkey" + columns: ["transaction_id"] + isOneToOne: false + referencedRelation: "transactions" + referencedColumns: ["id"] + }, + ] + } + transaction_webhook_events: { + Row: { + circle_event_id: string | null + circle_transaction_id: string | null + dedupe_hash: string + id: number + mapped_status: + | Database["public"]["Enums"]["transaction_status"] + | null + raw_payload: Json + received_at: string + signature_valid: boolean + transaction_id: string | null + } + Insert: { + circle_event_id?: string | null + circle_transaction_id?: string | null + dedupe_hash: string + id?: number + mapped_status?: + | Database["public"]["Enums"]["transaction_status"] + | null + raw_payload: Json + received_at?: string + signature_valid?: boolean + transaction_id?: string | null + } + Update: { + circle_event_id?: string | null + circle_transaction_id?: string | null + dedupe_hash?: string + id?: number + mapped_status?: + | Database["public"]["Enums"]["transaction_status"] + | null + raw_payload?: Json + received_at?: string + signature_valid?: boolean + transaction_id?: string | null + } + Relationships: [ + { + foreignKeyName: "transaction_webhook_events_transaction_id_fkey" + columns: ["transaction_id"] + isOneToOne: false + referencedRelation: "transactions" + referencedColumns: ["id"] + }, + ] + } + transactions: { + Row: { + amount_usdc: number + asset: string + chain: string + circle_transaction_id: string | null + created_at: string + credit_amount: number | null + destination_address: string | null + direction: Database["public"]["Enums"]["transaction_direction"] | null + exchange_rate: number | null + fee_usdc: number + id: string + idempotency_key: string + metadata: Json | null + source_wallet_id: string | null + status: Database["public"]["Enums"]["transaction_status"] + transaction_type: Database["public"]["Enums"]["transaction_type"] + tx_hash: string | null + updated_at: string + user_id: string | null + wallet_id: string + } + Insert: { + amount_usdc: number + asset?: string + chain: string + circle_transaction_id?: string | null + created_at?: string + credit_amount?: number | null + destination_address?: string | null + direction?: Database["public"]["Enums"]["transaction_direction"] | null + exchange_rate?: number | null + fee_usdc?: number + id?: string + idempotency_key: string + metadata?: Json | null + source_wallet_id?: string | null + status?: Database["public"]["Enums"]["transaction_status"] + transaction_type?: Database["public"]["Enums"]["transaction_type"] + tx_hash?: string | null + updated_at?: string + user_id?: string | null + wallet_id: string + } + Update: { + amount_usdc?: number + asset?: string + chain?: string + circle_transaction_id?: string | null + created_at?: string + credit_amount?: number | null + destination_address?: string | null + direction?: Database["public"]["Enums"]["transaction_direction"] | null + exchange_rate?: number | null + fee_usdc?: number + id?: string + idempotency_key?: string + metadata?: Json | null + source_wallet_id?: string | null + status?: Database["public"]["Enums"]["transaction_status"] + transaction_type?: Database["public"]["Enums"]["transaction_type"] + tx_hash?: string | null + updated_at?: string + user_id?: string | null + wallet_id?: string + } + Relationships: [ + { + foreignKeyName: "transactions_source_wallet_id_fkey" + columns: ["source_wallet_id"] + referencedRelation: "admin_wallets" + referencedColumns: ["id"] + } + ] + } + } + Views: { + [_ in never]: never + } + Functions: { + check_user_exists: { + Args: { user_email: string } + Returns: boolean + } + } + Enums: { + admin_transaction_status: "PENDING" | "CONFIRMED" | "FAILED" + admin_transaction_type: "STANDARD" | "CCTP_APPROVAL" | "CCTP_BURN" | "CCTP_MINT" + admin_wallet_status: "ENABLED" | "DISABLED" | "ARCHIVED" + transaction_direction: "credit" | "debit" + transaction_status: "pending" | "completed" | "confirmed" | "complete" | "failed" + transaction_type: "USER" | "ADMIN" | "CCTP_APPROVAL" | "CCTP_BURN" | "CCTP_MINT" + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + graphql_public: { + Enums: {}, + }, + public: { + Enums: { + admin_transaction_status: ["PENDING", "CONFIRMED", "FAILED"], + admin_transaction_type: ["STANDARD", "CCTP_APPROVAL", "CCTP_BURN"], + admin_wallet_status: ["ENABLED", "DISABLED", "ARCHIVED"], + transaction_direction: ["credit", "debit"], + transaction_status: ["pending", "completed", "confirmed", "complete", "failed"], + }, + }, +} as const + diff --git a/types/tanstack-table.d.ts b/types/tanstack-table.d.ts new file mode 100644 index 0000000..d844be4 --- /dev/null +++ b/types/tanstack-table.d.ts @@ -0,0 +1,41 @@ +/** + * 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 { FilterFn } from "@tanstack/react-table"; +import { RankingInfo } from "@tanstack/match-sorter-utils"; +import { Database } from "@/types/supabase"; +import { ConfirmableAction } from "@/components/admin-wallets-table/columns"; + +type Wallet = Database["public"]["Tables"]["admin_wallets"]["Row"]; + +declare module "@tanstack/table-core" { + interface FilterFns { + dateBetween?: FilterFn; + } + + interface TableMeta { + openConfirmationDialog?: (wallet: Wallet, action: ConfirmableAction) => void; + // This is the new line to add: + openTransferDialog?: (wallet: Wallet) => void; + openBalanceDialog?: (wallet: Wallet) => void; + } + + interface FilterMeta { + itemRank: RankingInfo; + } +} \ No newline at end of file