diff --git a/app/api/v1/wallets/deprecate/route.ts b/app/api/v1/wallets/deprecate/route.ts new file mode 100644 index 00000000..ad1d55b2 --- /dev/null +++ b/app/api/v1/wallets/deprecate/route.ts @@ -0,0 +1,176 @@ +import { NextRequest, NextResponse } from "next/server"; +import { supabaseAdmin } from "@/app/lib/supabase"; +import { withRateLimit } from "@/app/lib/rate-limit"; +import { trackApiRequest, trackApiResponse, trackApiError } from "@/app/lib/server-analytics"; +import { verifyJWT } from "@/app/lib/jwt"; +import { DEFAULT_PRIVY_CONFIG } from "@/app/lib/config"; + +export const POST = withRateLimit(async (request: NextRequest) => { + const startTime = Date.now(); + + try { + // Step 1: Verify authentication token + const authHeader = request.headers.get("Authorization"); + const token = authHeader?.replace("Bearer ", ""); + + if (!token) { + trackApiError(request, "/api/v1/wallets/deprecate", "POST", new Error("Unauthorized"), 401); + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + let authenticatedUserId: string; + try { + const jwtResult = await verifyJWT(token, DEFAULT_PRIVY_CONFIG); + authenticatedUserId = jwtResult.payload.sub; + + if (!authenticatedUserId) { + trackApiError(request, "/api/v1/wallets/deprecate", "POST", new Error("Invalid token"), 401); + return NextResponse.json( + { success: false, error: "Invalid token" }, + { status: 401 } + ); + } + } catch (jwtError) { + trackApiError(request, "/api/v1/wallets/deprecate", "POST", jwtError as Error, 401); + return NextResponse.json( + { success: false, error: "Invalid or expired token" }, + { status: 401 } + ); + } + + const walletAddress = request.headers.get("x-wallet-address")?.toLowerCase(); + const body = await request.json(); + const { oldAddress, newAddress, txHash, userId } = body; + + if (!walletAddress || !oldAddress || !newAddress || !userId) { + trackApiError(request, "/api/v1/wallets/deprecate", "POST", new Error("Missing required fields"), 400); + return NextResponse.json( + { success: false, error: "Missing required fields" }, + { status: 400 } + ); + } + + // Step 2: Verify userId matches authenticated user (CRITICAL SECURITY FIX) + if (userId !== authenticatedUserId) { + trackApiError(request, "/api/v1/wallets/deprecate", "POST", new Error("Unauthorized: userId mismatch"), 403); + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 403 } + ); + } + + // Step 3: Verify wallet addresses match + if (newAddress.toLowerCase() !== walletAddress) { + trackApiError(request, "/api/v1/wallets/deprecate", "POST", new Error("Wallet address mismatch"), 403); + return NextResponse.json( + { success: false, error: "Wallet address mismatch" }, + { status: 403 } + ); + } + + trackApiRequest(request, "/api/v1/wallets/deprecate", "POST", { + wallet_address: walletAddress, + old_address: oldAddress, + new_address: newAddress, + }); + + // Step 4: Atomic database operations with rollback on failure + // Ensure old (SCW) wallet exists and mark as deprecated (upsert so we insert if never saved to DB) + const now = new Date().toISOString(); + const { error: deprecateError } = await supabaseAdmin + .from("wallets") + .upsert( + { + address: oldAddress.toLowerCase(), + user_id: userId, + wallet_type: "smart_contract", + status: "deprecated", + deprecated_at: now, + migration_completed: true, + migration_tx_hash: txHash, + updated_at: now, + }, + { onConflict: "address,user_id" } + ); + + if (deprecateError) { + trackApiError(request, "/api/v1/wallets/deprecate", "POST", deprecateError, 500); + throw deprecateError; + } + + // Create or update new EOA wallet record + const { error: upsertError } = await supabaseAdmin + .from("wallets") + .upsert({ + address: newAddress.toLowerCase(), + user_id: userId, + wallet_type: "eoa", + status: "active", + created_at: new Date().toISOString(), + }); + + if (upsertError) { + // Rollback: Restore old wallet status + await supabaseAdmin + .from("wallets") + .update({ + status: "active", + deprecated_at: null, + migration_completed: false, + migration_tx_hash: null, + }) + .eq("address", oldAddress.toLowerCase()) + .eq("user_id", userId); + + trackApiError(request, "/api/v1/wallets/deprecate", "POST", upsertError, 500); + throw upsertError; + } + + // Migrate KYC data + const { error: kycError } = await supabaseAdmin + .from("kyc_data") + .update({ wallet_address: newAddress.toLowerCase() }) + .eq("wallet_address", oldAddress.toLowerCase()) + .eq("user_id", userId); + + if (kycError) { + console.error("KYC migration error:", kycError); + // Return partial success - wallet migrated but KYC migration failed + // This is better than rolling back the entire migration + const responseTime = Date.now() - startTime; + trackApiResponse("/api/v1/wallets/deprecate", "POST", 200, responseTime, { + wallet_address: walletAddress, + migration_successful: true, + kyc_migration_failed: true, + }); + + return NextResponse.json({ + success: true, + message: "Wallet migrated but KYC migration failed", + kycMigrationFailed: true, + }); + } + + const responseTime = Date.now() - startTime; + trackApiResponse("/api/v1/wallets/deprecate", "POST", 200, responseTime, { + wallet_address: walletAddress, + migration_successful: true, + }); + + return NextResponse.json({ success: true, message: "Wallet migrated successfully" }); + } catch (error) { + console.error("Error deprecating wallet:", error); + const responseTime = Date.now() - startTime; + trackApiError(request, "/api/v1/wallets/deprecate", "POST", error as Error, 500, { + response_time_ms: responseTime, + }); + + return NextResponse.json( + { success: false, error: "Internal server error" }, + { status: 500 } + ); + } +}); \ No newline at end of file diff --git a/app/api/v1/wallets/migration-status/route.ts b/app/api/v1/wallets/migration-status/route.ts new file mode 100644 index 00000000..8ccee84b --- /dev/null +++ b/app/api/v1/wallets/migration-status/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { supabaseAdmin } from "@/app/lib/supabase"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const userId = searchParams.get("userId"); + + if (!userId) { + return NextResponse.json({ error: "User ID required" }, { status: 400 }); + } + + // Check if user has completed migration + const { data, error } = await supabaseAdmin + .from("wallets") + .select("migration_completed, status, wallet_type") + .eq("user_id", userId) + .eq("wallet_type", "smart_contract") + .single(); + + // Handle specific error codes + if (error) { + // PGRST116 = no rows found (user has no smart wallet) - this is OK + if (error.code === "PGRST116") { + return NextResponse.json({ + migrationCompleted: false, + status: "unknown", + hasSmartWallet: false + }); + } + + // PGRST205 = table not found in schema cache (migration not run yet) + if (error.code === "PGRST205") { + console.warn("⚠️ Wallets table not found in schema cache. Migration may not be applied yet."); + return NextResponse.json({ + migrationCompleted: false, + status: "unknown", + hasSmartWallet: true, // Assume true to show banner + error: "Database schema not ready" + }, { status: 200 }); // Return 200 so frontend doesn't break + } + + // For other errors, log and return safe fallback + console.error("Database query error:", error); + return NextResponse.json({ + migrationCompleted: false, + status: "unknown", + hasSmartWallet: true, // Assume true to show banner on error + error: error.message + }, { status: 200 }); // Return 200 so frontend doesn't break + } + + return NextResponse.json({ + migrationCompleted: data?.migration_completed ?? false, + status: data?.status ?? "unknown", + hasSmartWallet: !!data + }); + } catch (error: any) { + // Handle connection errors (DNS, network, etc.) + const errorMessage = error?.message || String(error); + const isConnectionError = + errorMessage.includes("ENOTFOUND") || + errorMessage.includes("fetch failed") || + errorMessage.includes("ECONNREFUSED") || + errorMessage.includes("ETIMEDOUT"); + + if (isConnectionError) { + console.warn("⚠️ Database connection error, returning fallback response:", errorMessage); + return NextResponse.json({ + migrationCompleted: false, + status: "unknown", + hasSmartWallet: true, // Assume true to show banner if DB is down + error: "Database temporarily unavailable" + }, { status: 200 }); // Return 200 so frontend doesn't break + } + + console.error("Error checking migration status:", error); + return NextResponse.json({ + error: error instanceof Error ? error.message : "Internal server error", + migrationCompleted: false, + status: "error", + hasSmartWallet: true // Assume true to show banner on error + }, { status: 200 }); // Return 200 so frontend doesn't break + } +} \ No newline at end of file diff --git a/app/components/AppLayout.tsx b/app/components/AppLayout.tsx index cbe2c33c..ddbbf615 100644 --- a/app/components/AppLayout.tsx +++ b/app/components/AppLayout.tsx @@ -1,3 +1,4 @@ +"use client"; import React from "react"; import Script from "next/script"; import config from "../lib/config"; @@ -11,8 +12,10 @@ import { PWAInstall, NoticeBanner, } from "./index"; +import { MigrationBannerWrapper } from "../context"; export default function AppLayout({ children }: { children: React.ReactNode }) { + return (
@@ -21,6 +24,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { {config.noticeBannerText && ( )} +
}> {children} @@ -35,11 +39,10 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { {`window.BrevoConversationsID=${JSON.stringify(config.brevoConversationsId)}; window.BrevoConversations=window.BrevoConversations||function(){ (window.BrevoConversations.q=window.BrevoConversations.q||[]).push(arguments)}; - window.BrevoConversationsSetup=${ - config.brevoConversationsGroupId - ? `{groupId:${JSON.stringify(config.brevoConversationsGroupId)}}` + window.BrevoConversationsSetup=${config.brevoConversationsGroupId + ? `{groupId:${JSON.stringify(config.brevoConversationsGroupId)}}` : '{}' - }; + }; `}