Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions app/api/v1/wallets/deprecate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
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
// Mark old wallet as deprecated
const { error: deprecateError } = await supabaseAdmin
.from("wallets")
.update({
status: "deprecated",
deprecated_at: new Date().toISOString(),
migration_completed: true,
migration_tx_hash: txHash,
})
.eq("address", oldAddress.toLowerCase())
.eq("user_id", userId);

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 }
);
}
});
39 changes: 39 additions & 0 deletions app/api/v1/wallets/migration-status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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();

if (error && error.code !== "PGRST116") { // PGRST116 = no rows found
throw error;
}

return NextResponse.json({
migrationCompleted: data?.migration_completed ?? false,
status: data?.status ?? "unknown",
hasSmartWallet: !!data
});
} catch (error) {
console.error("Error checking migration status:", error);
return NextResponse.json({
error: "Internal server error",
migrationCompleted: false,
status: "unknown",
hasSmartWallet: false
}, { status: 500 });
}
}
4 changes: 4 additions & 0 deletions app/components/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
"use client";
import React from "react";
import Script from "next/script";
import config from "../lib/config";
Expand All @@ -11,8 +12,10 @@ import {
PWAInstall,
NoticeBanner,
} from "./index";
import { MigrationBannerWrapper } from "../context";

export default function AppLayout({ children }: { children: React.ReactNode }) {

return (
<Providers>
<div className="min-h-full min-w-full bg-white transition-colors dark:bg-neutral-900">
Expand All @@ -21,6 +24,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
{config.noticeBannerText && (
<NoticeBanner textLines={config.noticeBannerText.split("|")} />
)}
<MigrationBannerWrapper />
</div>
<LayoutWrapper footer={<Footer />}>
<MainContent>{children}</MainContent>
Expand Down
104 changes: 104 additions & 0 deletions app/components/WalletMigrationBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"use client";
import Image from "next/image";
import React, { useState } from "react";
import { motion } from "framer-motion";
import WalletMigrationModal from "./WalletMigrationModal";

export const WalletMigrationBanner = () => {
const [isModalOpen, setIsModalOpen] = useState(false);

const handleStartMigration = () => {
setIsModalOpen(true);
};

const handleCloseModal = () => {
setIsModalOpen(false);
};

return (
<>
<motion.div
className="fixed left-0 right-0 top-16 z-30 mt-1 hidden h-16 w-full items-center justify-center bg-[#2D77E2] px-0 sm:flex md:px-0"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
>
<div className="absolute left-0 z-10 flex items-center gap-3 py-4">
<div className="flex-shrink-0">
<Image
src="/images/desktop-eip-migration.png"
alt="Migration Illustration"
width={120}
height={80}
priority
className="h-auto w-auto"
/>
</div>

<div className="flex flex-col items-start justify-center gap-1 text-left text-sm font-medium leading-tight text-white/80">
<span className="block">
Noblocks is migrating, this is a legacy version that will be
closed by{" "}
<span className="font-semibold text-white">
6th June, 2025
</span>
. Click on start migration to move to the new version.
</span>
</div>
</div>

<div className="relative z-10 mx-auto flex w-full max-w-screen-2xl items-center justify-end px-4 py-4 sm:px-8 sm:py-4">
<div className="flex-shrink-0">
<button
onClick={handleStartMigration}
className="whitespace-nowrap rounded-xl bg-white px-6 py-2.5 text-sm font-semibold text-neutral-900 transition-all hover:bg-white/90 focus:outline-none focus:ring-2 focus:ring-white/50 focus:ring-offset-2 focus:ring-offset-[#2D77E2] active:bg-white/80"
>
Start migration
</button>
</div>
</div>
</motion.div>

<motion.div
className="fixed left-0 right-0 top-16 z-30 mt-1 flex w-full flex-col bg-[#2D77E2] px-4 py-6 sm:hidden"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
>
<div className="absolute left-0 top-0 z-0 h-full">
<Image
src="/images/mobile-eip-migration.png"
alt="Migration Illustration Mobile"
width={80}
height={100}
priority
className="h-40 w-auto object-contain"
/>
</div>

<div className="relative z-10 mb-6 pl-4 pr-10">
<p className="text-sm font-medium leading-relaxed text-white">
Noblocks is migrating, this is a legacy version that will be
closed by{" "}
<span className="font-bold text-white">6th June, 2025</span>.
Click on start migration to move to the new version.
</p>
</div>

<div className="relative z-10 pl-4">
<button
onClick={handleStartMigration}
className="rounded-xl bg-white px-8 py-3 text-base font-semibold text-neutral-900 transition-all hover:bg-white/90 active:bg-white/80"
>
Start migration
</button>
</div>
</motion.div>

<WalletMigrationModal
isOpen={isModalOpen}
onClose={handleCloseModal}
/>
</>
);
};
Loading