From 699bf8e65ba790ac1f2dc78b6a9235bb0efd6611 Mon Sep 17 00:00:00 2001 From: sundayonah Date: Tue, 30 Dec 2025 08:10:58 +0100 Subject: [PATCH 1/5] feat: implement wallet migration feature with modals and context support - Added WalletMigrationBanner and WalletMigrationModal components for user migration prompts. - Introduced WalletTransferApprovalModal for handling wallet transfer approvals. - Created hooks for managing EIP-7702 account setup and migration status checks. - Implemented backend API routes for deprecating old wallets and checking migration status. - Updated context to include migration-related components and logic. - Enhanced UI with new images and improved user feedback during migration processes. --- app/api/v1/wallets/deprecate/route.ts | 83 +++ app/api/v1/wallets/migration-status/route.ts | 37 ++ app/components/AppLayout.tsx | 4 + app/components/WalletMigrationBanner.tsx | 104 ++++ app/components/WalletMigrationModal.tsx | 216 ++++++++ .../WalletMigrationSuccessModal.tsx | 100 ++++ .../WalletTransferApprovalModal.tsx | 498 ++++++++++++++++++ app/components/index.ts | 1 + app/context/MigrationContext.tsx | 19 + app/context/index.ts | 1 + app/hooks/useEIP7702Account.ts | 161 ++++++ app/lib/privy-config.ts | 5 +- app/utils.ts | 2 +- public/images/checkmark-circle.svg | 4 + public/images/desktop-eip-migration.png | Bin 0 -> 5327 bytes public/images/mobile-eip-migration.png | Bin 0 -> 2843 bytes public/images/wallet.png | Bin 0 -> 1360 bytes .../migrations/create_wallet_migrations.sql | 73 +++ 18 files changed, 1305 insertions(+), 3 deletions(-) create mode 100644 app/api/v1/wallets/deprecate/route.ts create mode 100644 app/api/v1/wallets/migration-status/route.ts create mode 100644 app/components/WalletMigrationBanner.tsx create mode 100644 app/components/WalletMigrationModal.tsx create mode 100644 app/components/WalletMigrationSuccessModal.tsx create mode 100644 app/components/WalletTransferApprovalModal.tsx create mode 100644 app/context/MigrationContext.tsx create mode 100644 app/hooks/useEIP7702Account.ts create mode 100644 public/images/checkmark-circle.svg create mode 100644 public/images/desktop-eip-migration.png create mode 100644 public/images/mobile-eip-migration.png create mode 100644 public/images/wallet.png create mode 100644 supabase/migrations/create_wallet_migrations.sql diff --git a/app/api/v1/wallets/deprecate/route.ts b/app/api/v1/wallets/deprecate/route.ts new file mode 100644 index 00000000..72c906f0 --- /dev/null +++ b/app/api/v1/wallets/deprecate/route.ts @@ -0,0 +1,83 @@ +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"; + +export const POST = withRateLimit(async (request: NextRequest) => { + const startTime = Date.now(); + + try { + 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 } + ); + } + + trackApiRequest(request, "/api/v1/wallets/deprecate", "POST", { + wallet_address: walletAddress, + old_address: oldAddress, + new_address: newAddress, + }); + + // 1. 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) throw deprecateError; + + // 2. 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) throw upsertError; + + // 3. 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); + + 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..c10cd739 --- /dev/null +++ b/app/api/v1/wallets/migration-status/route.ts @@ -0,0 +1,37 @@ +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: 500 }); + } +} \ No newline at end of file diff --git a/app/components/AppLayout.tsx b/app/components/AppLayout.tsx index cbe2c33c..150ca037 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} diff --git a/app/components/WalletMigrationBanner.tsx b/app/components/WalletMigrationBanner.tsx new file mode 100644 index 00000000..6963cdbc --- /dev/null +++ b/app/components/WalletMigrationBanner.tsx @@ -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 ( + <> + +
+
+ Migration Illustration +
+ +
+ + Noblocks is migrating, this is a legacy version that will be + closed by{" "} + + 6th June, 2025 + + . Click on start migration to move to the new version. + +
+
+ +
+
+ +
+
+
+ + +
+ Migration Illustration Mobile +
+ +
+

+ Noblocks is migrating, this is a legacy version that will be + closed by{" "} + 6th June, 2025. + Click on start migration to move to the new version. +

+
+ +
+ +
+
+ + + + ); +}; \ No newline at end of file diff --git a/app/components/WalletMigrationModal.tsx b/app/components/WalletMigrationModal.tsx new file mode 100644 index 00000000..0cc97965 --- /dev/null +++ b/app/components/WalletMigrationModal.tsx @@ -0,0 +1,216 @@ +"use client"; +import React, { useState } from "react"; +import Image from "next/image"; +import { AnimatePresence, motion } from "framer-motion"; +import { Dialog, DialogPanel } from "@headlessui/react"; +import { Cancel01Icon } from "hugeicons-react"; +import WalletTransferApprovalModal from "./WalletTransferApprovalModal"; + +interface WalletMigrationModalProps { + isOpen: boolean; + onClose: () => void; +} + +const WalletMigrationModal: React.FC = ({ + isOpen, + onClose, +}) => { + const [showTransferModal, setShowTransferModal] = useState(false); + + const handleApproveMigration = () => { + onClose(); + setTimeout(() => { + setShowTransferModal(true); + }, 300); + }; + + const handleCloseTransferModal = () => { + setShowTransferModal(false); + }; + + return ( + <> + + {isOpen && ( + + + +
+ + + +
+ +
+ + {/* Content */} +
+

+ A short letter from us to you! +

+ +
+ Chibie +
+ +
+
+
+ Chibie +
+
+
+
+ Hello, we are migrating! +
+
+
+ + {/* Migration Overview */} +
+

+ We're upgrading to a faster, more secure + wallet powered by{" "} + + Thirdweb + + . What does this mean? +

+ + {/* KYC Migration Section */} +
+
+
+
+ KYC Migration +
+
+
+

+ Your KYC will be moved from{" "} + + Privy + {" "} + to a new wallet address assigned by{" "} + + Thirdweb + +

+
+
+ + {/* Funds Transfer Section */} +
+
+
+ Funds Transfer +
+
+
+

+ If you have any funds in your account, + it will be transferred to your new + KYCed address +

+
+
+
+ + {/* Call to Action Explanation */} +

+ All you have to do is approve both actions and + we will do all the heavy liftings for you +

+
+ + {/* Approve Migration Button */} + +
+
+
+
+
+
+ )} +
+ + + + ); +}; + +export default WalletMigrationModal; + diff --git a/app/components/WalletMigrationSuccessModal.tsx b/app/components/WalletMigrationSuccessModal.tsx new file mode 100644 index 00000000..86528f16 --- /dev/null +++ b/app/components/WalletMigrationSuccessModal.tsx @@ -0,0 +1,100 @@ +"use client"; +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Dialog, DialogPanel } from "@headlessui/react"; +import { Cancel01Icon, CheckmarkCircle01Icon } from "hugeicons-react"; + +interface WalletMigrationSuccessModalProps { + isOpen: boolean; + onClose: () => void; +} + +const WalletMigrationSuccessModal: React.FC = ({ + isOpen, + onClose, +}) => { + + const handleContinue = () => { + onClose(); + }; + + return ( + + {isOpen && ( + + + +
+ + + +
+ +
+ +
+
+ +
+ +

+ Migration successful +

+

+ You can now continue converting your crypto to
fiats at zero fees on noblocks +

+ +
+
+
+
+
+
+ )} +
+ ); +}; + +export default WalletMigrationSuccessModal; diff --git a/app/components/WalletTransferApprovalModal.tsx b/app/components/WalletTransferApprovalModal.tsx new file mode 100644 index 00000000..4a5258fc --- /dev/null +++ b/app/components/WalletTransferApprovalModal.tsx @@ -0,0 +1,498 @@ +"use client"; +import React, { useState, useMemo, useEffect } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Dialog, DialogPanel } from "@headlessui/react"; +import { Cancel01Icon, Wallet01Icon, InformationCircleIcon } from "hugeicons-react"; +import Image from "next/image"; +import { useBalance } from "../context/BalanceContext"; +import { useTokens } from "../context"; +import { useNetwork } from "../context/NetworksContext"; +import { usePrivy, useWallets } from "@privy-io/react-auth"; +import { useSmartWallets } from "@privy-io/react-auth/smart-wallets"; +import { formatCurrency, shortenAddress, getNetworkImageUrl, fetchWalletBalance } from "../utils"; +import { useActualTheme } from "../hooks/useActualTheme"; +import { useCNGNRate } from "../hooks/useCNGNRate"; +import WalletMigrationSuccessModal from "./WalletMigrationSuccessModal"; +import { type Address, encodeFunctionData, parseAbi, createPublicClient, http } from "viem"; +import { toast } from "sonner"; +import { networks } from "../mocks"; + +// Map network names to viem chains +const CHAIN_MAP = Object.fromEntries( + networks.map(n => [n.chain.name, n.chain]) +); + +interface WalletTransferApprovalModalProps { + isOpen: boolean; + onClose: () => void; +} + +const WalletTransferApprovalModal: React.FC = ({ + isOpen, + onClose, +}) => { + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const [progress, setProgress] = useState(""); + const [allChainBalances, setAllChainBalances] = useState>>({}); + const [isFetchingBalances, setIsFetchingBalances] = useState(false); + + const { allBalances, isLoading } = useBalance(); + const { allTokens } = useTokens(); + const { selectedNetwork } = useNetwork(); + const { user, getAccessToken } = usePrivy(); + const { wallets } = useWallets(); + const { client: smartWalletClient } = useSmartWallets(); + const isDark = useActualTheme(); + const { rate } = useCNGNRate({ + network: selectedNetwork.chain.name, + dependencies: [selectedNetwork], + }); + + // Get wallet addresses + const smartWallet = user?.linkedAccounts.find(a => a.type === "smart_wallet"); + const embeddedWallet = wallets.find(w => w.walletClientType === "privy"); + + const oldAddress = smartWallet?.address; // SCW + const newAddress = embeddedWallet?.address; // EOA + + // -------------------- Fetch balances from all networks -------------------- + useEffect(() => { + if (!isOpen || !oldAddress) return; + + const fetchAllChainBalances = async () => { + setIsFetchingBalances(true); + const balancesByChain: Record> = {}; + + // Fetch balances for each supported network + for (const network of networks) { + try { + const publicClient = createPublicClient({ + chain: network.chain, + transport: http(), + }); + + const result = await fetchWalletBalance( + publicClient, + oldAddress + ); + + // Only include chains with non-zero balances + const hasBalance = Object.values(result.balances).some(b => b > 0); + if (hasBalance) { + balancesByChain[network.chain.name] = result.balances; + } + } catch (error) { + console.error(`Error fetching balances for ${network.chain.name}:`, error); + } + } + + setAllChainBalances(balancesByChain); + setIsFetchingBalances(false); + }; + + fetchAllChainBalances(); + }, [isOpen, oldAddress]); + + // -------------------- Group tokens by chain from all networks -------------------- + const tokensByChain = useMemo(() => { + const grouped: Record = {}; + + // Process balances from all chains + for (const [chainName, balances] of Object.entries(allChainBalances)) { + const fetchedTokens = allTokens[chainName] || []; + + for (const [symbol, balance] of Object.entries(balances)) { + const balanceNum = balance as number; + if (balanceNum <= 0) continue; + + const tokenMeta = fetchedTokens.find(t => + t.symbol.toUpperCase() === symbol.toUpperCase() + ); + + if (tokenMeta?.address) { + // Get CNGN rate for this chain if needed + const chainRate = symbol.toUpperCase() === "CNGN" ? rate : undefined; + let usdValue = balanceNum; + if (chainRate) { + usdValue = balanceNum / chainRate; + } + + const token = { + id: `${chainName}-${symbol}`, + chain: chainName, + name: tokenMeta.name || symbol, + symbol, + amount: balanceNum, + displayAmount: balanceNum.toFixed(2), + value: `${usdValue.toFixed(2)}`, + icon: `/logos/${symbol.toLowerCase()}-logo.svg`, + address: tokenMeta.address, + decimals: tokenMeta.decimals || 18, + }; + + if (!grouped[chainName]) { + grouped[chainName] = []; + } + + grouped[chainName].push(token); + } else { + console.warn(`⚠️ No metadata found for ${symbol} on ${chainName}`); + } + } + } + + return grouped; + }, [allChainBalances, allTokens, rate]); + + // Flatten for display + const tokens = useMemo(() => { + return Object.values(tokensByChain).flat(); + }, [tokensByChain]); + + const totalBalance = useMemo(() => { + const sum = tokens.reduce((acc, t) => acc + parseFloat(t.value || "0"), 0); + return formatCurrency(sum, "USD", "en-US"); + }, [tokens]); + + // Helper to get network config for a chain name + const getNetworkConfig = (chainName: string) => { + return networks.find(n => n.chain.name === chainName); + }; + + const handleCloseSuccessModal = () => setShowSuccessModal(false); + + // Handle approve transfer + const handleApproveTransfer = async () => { + if (!user || !oldAddress || !newAddress || !embeddedWallet) { + setError("Wallet not ready"); + return; + } + + // ✅ Allow migration even with zero balance - need to deprecate old SCW + const hasTokens = tokens.length > 0; + + if (!hasTokens) { + // No tokens to transfer, but still need to deprecate old wallet + setProgress("No tokens to migrate, but deprecating old wallet..."); + } + + setIsProcessing(true); + setError(null); + setProgress(hasTokens ? "Initializing migration..." : "Deprecating old wallet..."); + + try { + const accessToken = await getAccessToken(); + if (!accessToken) throw new Error("Failed to get access token"); + + const allTxHashes: string[] = []; + const chains = Object.keys(tokensByChain); + + // ✅ If tokens exist, process transfers + if (hasTokens) { + // ✅ Check if smart wallet client is available + if (!smartWalletClient) { + throw new Error("Smart wallet client not available. Please ensure you have a smart wallet linked."); + } + + for (let i = 0; i < chains.length; i++) { + const chainName = chains[i]; + const chainTokens = tokensByChain[chainName]; + const chain = CHAIN_MAP[chainName]; + + if (!chain) { + console.warn(`Chain ${chainName} not supported, skipping...`); + continue; + } + + setProgress(`Processing ${chainName} (${i + 1}/${chains.length})...`); + + try { + // ✅ Switch to the correct chain using smart wallet client + await smartWalletClient.switchChain({ + id: chain.id, + }); + + // ✅ Create public client for waiting for receipts + const publicClient = createPublicClient({ + chain, + transport: http(), + }); + + // ✅ Batch all token transfers into a single transaction for gasless execution + // This uses Privy's smart wallet batch capability with Biconomy paymaster + const calls = chainTokens.map((token) => { + const amountInWei = BigInt( + Math.floor(token.amount * Math.pow(10, token.decimals)) + ); + + // ✅ Encode the transfer function call + const transferData = encodeFunctionData({ + abi: parseAbi(["function transfer(address to, uint256 amount) returns (bool)"]), + functionName: "transfer", + args: [newAddress as Address, amountInWei], + }); + + return { + to: token.address as `0x${string}`, + data: transferData as `0x${string}`, + value: BigInt(0), + }; + }); + + if (calls.length === 0) { + console.warn(`No tokens to migrate on ${chainName}`); + continue; + } + + setProgress(`Transferring ${calls.length} token(s) on ${chainName} (gasless)...`); + + // ✅ Send batched transaction from SCW + const txHash = (await smartWalletClient.sendTransaction({ + calls, + })) as `0x${string}`; + + allTxHashes.push(txHash); + + // ✅ Wait for transaction confirmation + setProgress(`Confirming ${chainName} migration...`); + + const receipt = await publicClient.waitForTransactionReceipt({ + hash: txHash as `0x${string}`, + confirmations: 1, + }); + + if (receipt.status === 'success') { + toast.success(`${chainName} migration complete!`, { + description: `${calls.length} token(s) transferred to your EOA (gasless)` + }); + } else { + throw new Error(`Transaction failed for ${chainName}`); + } + + toast.success(`${chainName} migration complete!`); + + } catch (chainError) { + const errorMsg = chainError instanceof Error ? chainError.message : 'Unknown error'; + toast.error(`Failed to migrate ${chainName}`, { + description: errorMsg + }); + } + } + } // End of hasTokens block + + // ✅ Update Backend - Deprecate old wallet + // This happens regardless of whether tokens were transferred + // (even if zero balance, we need to deprecate the old SCW) + setProgress("Finalizing migration..."); + + const response = await fetch("/api/v1/wallets/deprecate", { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "x-wallet-address": newAddress, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + oldAddress, + newAddress, + txHash: allTxHashes.length > 0 ? allTxHashes[0] : null, // null if no transactions + userId: user.id + }), + }); + + if (!response.ok) { + throw new Error("Failed to update backend"); + } + + toast.success("🎉 Migration Complete!", { + description: hasTokens + ? `${allTxHashes.length} token(s) successfully migrated to your EOA` + : "Old wallet deprecated successfully", + duration: 5000, + }); + + onClose(); + setTimeout(() => setShowSuccessModal(true), 300); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Migration failed"; + setError(errorMessage); + toast.error("Migration failed", { + description: errorMessage, + }); + } finally { + setIsProcessing(false); + setProgress(""); + } + }; + + // -------------------- UI -------------------- + const displayOldAddress = shortenAddress(oldAddress ?? "", 6); + const displayNewAddress = shortenAddress(newAddress ?? "", 6); + + return ( + <> + + {isOpen && ( + + + +
+ + + +
+ +
+ +
+

Wallet Migration

+ + {/* Old Wallet Balance Card */} +
+
+ + Smart Contract Wallet +
+
+ {displayOldAddress} +
+
+ Total balance +
+
{isLoading ? "..." : totalBalance}
+
+ + {/* Progress indicator */} + {progress && ( +
+
+
+ {progress} +
+
+ )} + + {/* Token List */} +
+ {isFetchingBalances || isLoading ? ( +
Loading balances from all networks...
+ ) : tokens.length === 0 ? ( +
No tokens found
+ ) : ( + tokens.map((token) => ( +
+
+
+ {token.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + {(() => { + const networkConfig = getNetworkConfig(token.chain); + if (!networkConfig) return null; + const imageUrl = typeof networkConfig.imageUrl === 'string' + ? networkConfig.imageUrl + : (isDark ? networkConfig.imageUrl.dark : networkConfig.imageUrl.light); + return ( + {token.chain} + ); + })()} +
+
+ {token.name} + + {token.displayAmount} {token.symbol} + +
+
+
+ ${token.value} +
+
+ )) + )} +
+ + {/* Info Banner */} +
+ +

+ Your funds will be transferred to your new EOA address{" "} + {displayNewAddress}. + This EOA can be upgraded with EIP-7702 for enhanced features. +

+
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Approve Transfer Button */} + +
+ + + +
+
+ )} +
+ + + + ); +}; + +export default WalletTransferApprovalModal; \ No newline at end of file diff --git a/app/components/index.ts b/app/components/index.ts index fcb264eb..e84f5786 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -54,4 +54,5 @@ export { FundWalletForm } from "./FundWalletForm"; export { TransferForm } from "./TransferForm"; export { default as NoticeBanner } from "./NoticeBanner"; +export { WalletMigrationBanner } from "./WalletMigrationBanner"; export { default as AppLayout } from "./AppLayout"; diff --git a/app/context/MigrationContext.tsx b/app/context/MigrationContext.tsx new file mode 100644 index 00000000..211852ad --- /dev/null +++ b/app/context/MigrationContext.tsx @@ -0,0 +1,19 @@ +"use client"; +import { useWalletMigrationStatus } from "../hooks/useEIP7702Account"; +import { WalletMigrationBanner } from "../components"; + +export const MigrationBannerWrapper = () => { + const { needsMigration, isChecking } = useWalletMigrationStatus(); + + // Don't show banner while checking + if (isChecking) { + return null; + } + + // Don't show banner if migration not needed + if (!needsMigration) { + return null; + } + + return ; +} \ No newline at end of file diff --git a/app/context/index.ts b/app/context/index.ts index e82de079..c584ef89 100644 --- a/app/context/index.ts +++ b/app/context/index.ts @@ -12,3 +12,4 @@ export { BlockFestModalProvider, useBlockFestModal, } from "./BlockFestModalContext"; +export { MigrationBannerWrapper } from "./MigrationContext"; \ No newline at end of file diff --git a/app/hooks/useEIP7702Account.ts b/app/hooks/useEIP7702Account.ts new file mode 100644 index 00000000..d6ec761f --- /dev/null +++ b/app/hooks/useEIP7702Account.ts @@ -0,0 +1,161 @@ +"use client"; +import { usePrivy, useWallets } from "@privy-io/react-auth"; +import { useEffect, useState } from "react"; +import { type Address, type WalletClient } from "viem"; +import { useBalance } from "../context/BalanceContext"; + +// ################################################ +// ############## EIP7702 ACCOUNT ################# +// ################################################ + +interface EIP7702Account { + eoaAddress: Address | null; + smartWalletAddress: Address | null; + signer: WalletClient | null; + isReady: boolean; +} + +/** + * Hook to setup EIP-7702 account with EOA and Smart Wallet + * This creates the wallet client needed for migration + */ +export function useEIP7702Account(): EIP7702Account { + const { user, authenticated } = usePrivy(); + const { wallets } = useWallets(); + const [account, setAccount] = useState({ + eoaAddress: null, + smartWalletAddress: null, + signer: null, + isReady: false, + }); + + useEffect(() => { + async function setupAccount() { + if (!user || !authenticated) { + setAccount({ + eoaAddress: null, + smartWalletAddress: null, + signer: null, + isReady: false, + }); + return; + } + + const embeddedWallet = wallets.find( + (wallet) => wallet.walletClientType === "privy" + ); + + const smartWallet = user.linkedAccounts.find( + (account) => account.type === "smart_wallet" + ); + + if (!smartWallet) { + setAccount({ + eoaAddress: embeddedWallet?.address as Address ?? null, + smartWalletAddress: null, + signer: null, + isReady: true, + }); + return; + } + + if (!embeddedWallet) { + console.warn("⚠️ [EIP7702] User has smart wallet but no embedded wallet"); + return; + } + + setAccount({ + eoaAddress: embeddedWallet.address as Address, + smartWalletAddress: smartWallet.address as Address, + signer: null, + isReady: true, + }); + } + + setupAccount(); + }, [authenticated, user?.id, wallets.length]); + + return account; +} + +// ################################################ +// ########## WALLET MIGRATION STATUS ############# +// ################################################ + +interface WalletMigrationStatus { + needsMigration: boolean; + isChecking: boolean; +} + +/** + * Hook to check if wallet migration is needed + * Uses real blockchain balances from useBalance + */ +export function useWalletMigrationStatus(): WalletMigrationStatus { + const { user, authenticated } = usePrivy(); + const { allBalances, isLoading: isBalanceLoading } = useBalance(); + const [needsMigration, setNeedsMigration] = useState(false); + const [isChecking, setIsChecking] = useState(true); + + useEffect(() => { + async function checkMigrationStatus() { + if (!authenticated || !user) { + setNeedsMigration(false); + setIsChecking(false); + return; + } + + // Check if user has smart wallet + const smartWallet = user.linkedAccounts.find( + (account) => account.type === "smart_wallet" + ); + + if (!smartWallet) { + setNeedsMigration(false); + setIsChecking(false); + return; + } + + // Wait for balances to load + if (isBalanceLoading) { + return; + } + + // ✅ Get balance directly from useBalance hook + const smartWalletBalance = allBalances.smartWallet?.total ?? 0; + + // Check if already migrated in database FIRST + try { + const response = await fetch(`/api/v1/wallets/migration-status?userId=${user.id}`); + + if (response.ok) { + const data = await response.json(); + const alreadyMigrated = data.migrationCompleted ?? false; + + // If already migrated, no need to show banner + if (alreadyMigrated) { + setNeedsMigration(false); + setIsChecking(false); + return; + } + + setNeedsMigration(true); + setIsChecking(false); + return; + } else { + const hasBalance = smartWalletBalance > 0; + setNeedsMigration(hasBalance || !!smartWallet); + } + } catch (error) { + const hasBalance = smartWalletBalance > 0; + setNeedsMigration(hasBalance || !!smartWallet); + } + + setIsChecking(false); + } + + checkMigrationStatus(); + }, [authenticated, user?.id, allBalances.smartWallet?.total, isBalanceLoading]); + + return { needsMigration, isChecking }; +} \ No newline at end of file diff --git a/app/lib/privy-config.ts b/app/lib/privy-config.ts index 7be1a66d..2eb265e3 100644 --- a/app/lib/privy-config.ts +++ b/app/lib/privy-config.ts @@ -1,4 +1,4 @@ -import { arbitrum, base, bsc, polygon, lisk, mainnet} from "viem/chains"; +import { arbitrum, base, bsc, polygon, lisk, mainnet, baseSepolia } from "viem/chains"; import { addRpcUrlOverrideToChain, type PrivyClientConfig, @@ -25,7 +25,8 @@ const baseConfig: Omit = { }, }, }, - supportedChains: [mainnet, base, bscOverride, arbitrum, polygon, lisk], + supportedChains: [mainnet, base, baseSepolia, bscOverride, arbitrum, polygon, lisk], + defaultChain: baseSepolia, }; export const lightModeConfig: PrivyClientConfig = { diff --git a/app/utils.ts b/app/utils.ts index c106aa7f..026227c4 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -375,7 +375,7 @@ export const FALLBACK_TOKENS: { [key: string]: Token[] } = { * The TokensContext handles bulk fetching for all networks. * * @param network - The network name (e.g., "Base", "Arbitrum One") - * @returns Array of supported tokens for the specified network + * @returns Array of supported tokens for the specified networkeeeeeeeeeeeeeeeeeeeeeeeeeee */ // Track ongoing fetch to prevent race conditions diff --git a/public/images/checkmark-circle.svg b/public/images/checkmark-circle.svg new file mode 100644 index 00000000..b4e88ff0 --- /dev/null +++ b/public/images/checkmark-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/desktop-eip-migration.png b/public/images/desktop-eip-migration.png new file mode 100644 index 0000000000000000000000000000000000000000..5d4b0f98d2e80ee764e6135c9ba35d09980a8df2 GIT binary patch literal 5327 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 literal 0 HcmV?d00001 diff --git a/public/images/mobile-eip-migration.png b/public/images/mobile-eip-migration.png new file mode 100644 index 0000000000000000000000000000000000000000..e370d472d764a43b34c6b9e14aa7dfaa6084fb1c GIT binary patch literal 2843 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 S)b?=z0000>'sub' + ) +); + From 897150977d57dd10a4e2a7aaab9cebd51d4fc976 Mon Sep 17 00:00:00 2001 From: sundayonah Date: Tue, 30 Dec 2025 08:20:11 +0100 Subject: [PATCH 2/5] fix: clean up documentation and remove unused chain reference in Privy config - Corrected the return type documentation in the TokensContext function. - Removed baseSepolia from the supportedChains list in PrivyClientConfig as it is no longer needed. --- app/lib/privy-config.ts | 5 ++--- app/utils.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/lib/privy-config.ts b/app/lib/privy-config.ts index 2eb265e3..e12215f1 100644 --- a/app/lib/privy-config.ts +++ b/app/lib/privy-config.ts @@ -1,4 +1,4 @@ -import { arbitrum, base, bsc, polygon, lisk, mainnet, baseSepolia } from "viem/chains"; +import { arbitrum, base, bsc, polygon, lisk, mainnet } from "viem/chains"; import { addRpcUrlOverrideToChain, type PrivyClientConfig, @@ -25,8 +25,7 @@ const baseConfig: Omit = { }, }, }, - supportedChains: [mainnet, base, baseSepolia, bscOverride, arbitrum, polygon, lisk], - defaultChain: baseSepolia, + supportedChains: [mainnet, base, bscOverride, arbitrum, polygon, lisk], }; export const lightModeConfig: PrivyClientConfig = { diff --git a/app/utils.ts b/app/utils.ts index 026227c4..c106aa7f 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -375,7 +375,7 @@ export const FALLBACK_TOKENS: { [key: string]: Token[] } = { * The TokensContext handles bulk fetching for all networks. * * @param network - The network name (e.g., "Base", "Arbitrum One") - * @returns Array of supported tokens for the specified networkeeeeeeeeeeeeeeeeeeeeeeeeeee + * @returns Array of supported tokens for the specified network */ // Track ongoing fetch to prevent race conditions From f33f6eb79f3b87b3463364cf5da782341ec7e1d3 Mon Sep 17 00:00:00 2001 From: sundayonah Date: Tue, 30 Dec 2025 08:49:27 +0100 Subject: [PATCH 3/5] feat: enhance wallet deprecation API with JWT authentication and user validation - Implemented JWT verification for secure access to the wallet deprecation endpoint. - Added checks to ensure the authenticated user matches the provided userId and wallet address. - Improved error handling for unauthorized access and token validation. - Introduced rollback mechanisms for database operations to maintain data integrity during migration. - Updated KYC data migration logic to allow for partial success reporting. --- app/api/v1/wallets/deprecate/route.ts | 99 +++++++++++++++++-- app/api/v1/wallets/migration-status/route.ts | 4 +- .../WalletTransferApprovalModal.tsx | 10 +- app/hooks/useEIP7702Account.ts | 13 ++- .../migrations/create_wallet_migrations.sql | 7 +- 5 files changed, 114 insertions(+), 19 deletions(-) diff --git a/app/api/v1/wallets/deprecate/route.ts b/app/api/v1/wallets/deprecate/route.ts index 72c906f0..440659a6 100644 --- a/app/api/v1/wallets/deprecate/route.ts +++ b/app/api/v1/wallets/deprecate/route.ts @@ -2,11 +2,45 @@ 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; @@ -19,13 +53,32 @@ export const POST = withRateLimit(async (request: NextRequest) => { ); } + // 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, }); - // 1. Mark old wallet as deprecated + // Step 4: Atomic database operations with rollback on failure + // Mark old wallet as deprecated const { error: deprecateError } = await supabaseAdmin .from("wallets") .update({ @@ -37,9 +90,12 @@ export const POST = withRateLimit(async (request: NextRequest) => { .eq("address", oldAddress.toLowerCase()) .eq("user_id", userId); - if (deprecateError) throw deprecateError; + if (deprecateError) { + trackApiError(request, "/api/v1/wallets/deprecate", "POST", deprecateError, 500); + throw deprecateError; + } - // 2. Create or update new EOA wallet record + // Create or update new EOA wallet record const { error: upsertError } = await supabaseAdmin .from("wallets") .upsert({ @@ -50,16 +106,47 @@ export const POST = withRateLimit(async (request: NextRequest) => { created_at: new Date().toISOString(), }); - if (upsertError) throw upsertError; + 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); - // 3. Migrate KYC data + 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); + 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, { diff --git a/app/api/v1/wallets/migration-status/route.ts b/app/api/v1/wallets/migration-status/route.ts index c10cd739..82cd18ed 100644 --- a/app/api/v1/wallets/migration-status/route.ts +++ b/app/api/v1/wallets/migration-status/route.ts @@ -31,7 +31,9 @@ export async function GET(request: NextRequest) { console.error("Error checking migration status:", error); return NextResponse.json({ error: "Internal server error", - migrationCompleted: false + migrationCompleted: false, + status: "unknown", + hasSmartWallet: false }, { status: 500 }); } } \ No newline at end of file diff --git a/app/components/WalletTransferApprovalModal.tsx b/app/components/WalletTransferApprovalModal.tsx index 4a5258fc..1455f83c 100644 --- a/app/components/WalletTransferApprovalModal.tsx +++ b/app/components/WalletTransferApprovalModal.tsx @@ -13,7 +13,7 @@ import { formatCurrency, shortenAddress, getNetworkImageUrl, fetchWalletBalance import { useActualTheme } from "../hooks/useActualTheme"; import { useCNGNRate } from "../hooks/useCNGNRate"; import WalletMigrationSuccessModal from "./WalletMigrationSuccessModal"; -import { type Address, encodeFunctionData, parseAbi, createPublicClient, http } from "viem"; +import { type Address, encodeFunctionData, parseAbi, createPublicClient, http, parseUnits } from "viem"; import { toast } from "sonner"; import { networks } from "../mocks"; @@ -223,8 +223,10 @@ const WalletTransferApprovalModal: React.FC = // ✅ Batch all token transfers into a single transaction for gasless execution // This uses Privy's smart wallet batch capability with Biconomy paymaster const calls = chainTokens.map((token) => { - const amountInWei = BigInt( - Math.floor(token.amount * Math.pow(10, token.decimals)) + // Use parseUnits for precise decimal handling (avoids floating-point precision loss) + const amountInWei = parseUnits( + token.amount.toString(), + token.decimals ); // ✅ Encode the transfer function call @@ -271,8 +273,6 @@ const WalletTransferApprovalModal: React.FC = throw new Error(`Transaction failed for ${chainName}`); } - toast.success(`${chainName} migration complete!`); - } catch (chainError) { const errorMsg = chainError instanceof Error ? chainError.message : 'Unknown error'; toast.error(`Failed to migrate ${chainName}`, { diff --git a/app/hooks/useEIP7702Account.ts b/app/hooks/useEIP7702Account.ts index d6ec761f..3983d7b1 100644 --- a/app/hooks/useEIP7702Account.ts +++ b/app/hooks/useEIP7702Account.ts @@ -61,6 +61,13 @@ export function useEIP7702Account(): EIP7702Account { if (!embeddedWallet) { console.warn("⚠️ [EIP7702] User has smart wallet but no embedded wallet"); + // Set account state to prevent perpetual loading + setAccount({ + eoaAddress: null, + smartWalletAddress: smartWallet.address as Address, + signer: null, + isReady: true, // Set to true to prevent loading state + }); return; } @@ -143,12 +150,14 @@ export function useWalletMigrationStatus(): WalletMigrationStatus { setIsChecking(false); return; } else { + // Fallback: show banner if there's balance const hasBalance = smartWalletBalance > 0; - setNeedsMigration(hasBalance || !!smartWallet); + setNeedsMigration(hasBalance); } } catch (error) { + // Fallback: show banner if there's balance const hasBalance = smartWalletBalance > 0; - setNeedsMigration(hasBalance || !!smartWallet); + setNeedsMigration(hasBalance); } setIsChecking(false); diff --git a/supabase/migrations/create_wallet_migrations.sql b/supabase/migrations/create_wallet_migrations.sql index 8f99b59a..813a9e48 100644 --- a/supabase/migrations/create_wallet_migrations.sql +++ b/supabase/migrations/create_wallet_migrations.sql @@ -60,14 +60,11 @@ using (true) with check (true); -- Users can read their own migration status (if needed for future client-side queries) --- Note: This uses the wallet_address from the request context set by middleware +-- Note: This verifies that privy_user_id matches the JWT sub claim create policy "Users can read their own migrations" on public.wallet_migrations for select to authenticated using ( - exists ( - select 1 from auth.users - where auth.users.id::text = current_setting('request.jwt.claims', true)::json->>'sub' - ) + privy_user_id = current_setting('request.jwt.claims', true)::json->>'sub' ); From 25453bc40e7e2e51483c66468758babbead76e1b Mon Sep 17 00:00:00 2001 From: sundayonah Date: Sat, 17 Jan 2026 08:00:01 +0100 Subject: [PATCH 4/5] feat: implement wallet migration status handling and update wallet selection logic - Introduced useMigrationStatus hook to check and manage wallet migration status. - Updated components (MobileDropdown, Navbar, SettingsDropdown, WalletDetails, TransactionForm, TransactionPreview) to utilize migration status for determining active wallet and balance. - Enhanced error handling in migration status API response to improve user experience during wallet transitions. - Adjusted balance fetching logic to prioritize embedded wallets post-migration. - Refactored wallet selection logic to ensure correct wallet type is displayed based on migration state. --- app/api/v1/wallets/migration-status/route.ts | 60 +++++++++++++++--- app/components/MobileDropdown.tsx | 56 +++++++++++------ app/components/Navbar.tsx | 24 +++++-- app/components/SettingsDropdown.tsx | 20 +++++- app/components/WalletDetails.tsx | 29 +++++++-- .../WalletTransferApprovalModal.tsx | 52 +++++++++++---- app/context/BalanceContext.tsx | 52 ++++++++++++--- app/hooks/useEIP7702Account.ts | 44 +++++++++++++ app/pages/TransactionForm.tsx | 63 ++++++++++++------- app/pages/TransactionPreview.tsx | 53 +++++++++++----- app/utils.ts | 32 +++++----- 11 files changed, 374 insertions(+), 111 deletions(-) diff --git a/app/api/v1/wallets/migration-status/route.ts b/app/api/v1/wallets/migration-status/route.ts index 82cd18ed..8ccee84b 100644 --- a/app/api/v1/wallets/migration-status/route.ts +++ b/app/api/v1/wallets/migration-status/route.ts @@ -18,8 +18,36 @@ export async function GET(request: NextRequest) { .eq("wallet_type", "smart_contract") .single(); - if (error && error.code !== "PGRST116") { // PGRST116 = no rows found - throw error; + // 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({ @@ -27,13 +55,31 @@ export async function GET(request: NextRequest) { status: data?.status ?? "unknown", hasSmartWallet: !!data }); - } catch (error) { + } 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: "Internal server error", + error: error instanceof Error ? error.message : "Internal server error", migrationCompleted: false, - status: "unknown", - hasSmartWallet: false - }, { status: 500 }); + 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/MobileDropdown.tsx b/app/components/MobileDropdown.tsx index 4a958b08..18bbac6b 100644 --- a/app/components/MobileDropdown.tsx +++ b/app/components/MobileDropdown.tsx @@ -2,7 +2,7 @@ import { Dialog, DialogPanel } from "@headlessui/react"; import { AnimatePresence, motion } from "framer-motion"; import { useState, useEffect } from "react"; -import { usePrivy, useMfaEnrollment } from "@privy-io/react-auth"; +import { usePrivy, useMfaEnrollment, useWallets } from "@privy-io/react-auth"; import { useNetwork } from "../context/NetworksContext"; import { useBalance, useTokens } from "../context"; import { handleNetworkSwitch, detectWalletProvider } from "../utils"; @@ -22,6 +22,7 @@ import { WalletView, HistoryView, SettingsView } from "./wallet-mobile-modal"; import { slideUpAnimation } from "./AnimatedComponents"; import { FundWalletForm, TransferForm } from "./index"; import { CopyAddressWarningModal } from "./CopyAddressWarningModal"; +import { useMigrationStatus } from "../hooks/useEIP7702Account"; export const MobileDropdown = ({ isOpen, @@ -39,6 +40,7 @@ export const MobileDropdown = ({ const { selectedNetwork, setSelectedNetwork } = useNetwork(); const { user, linkEmail, updateEmail } = usePrivy(); + const { wallets } = useWallets(); const { allBalances, isLoading, refreshBalance } = useBalance(); const { allTokens } = useTokens(); const { logout } = useLogout({ @@ -47,16 +49,29 @@ export const MobileDropdown = ({ }, }); const { isInjectedWallet, injectedAddress } = useInjectedWallet(); + const { isMigrationComplete } = useMigrationStatus(); + // Get embedded wallet (EOA) and smart wallet (SCW) + const embeddedWallet = wallets.find( + (wallet) => wallet.walletClientType === "privy" + ); + const smartWallet = user?.linkedAccounts.find( + (account) => account.type === "smart_wallet" + ); + + // Determine active wallet based on migration status + // After migration: show EOA (new wallet with funds) + // Before migration: show SCW (old wallet) const activeWallet = isInjectedWallet ? { address: injectedAddress, type: "injected_wallet" } - : user?.linkedAccounts.find((account) => account.type === "smart_wallet"); + : isMigrationComplete && embeddedWallet + ? { address: embeddedWallet.address, type: "eoa" } + : smartWallet; const { handleFundWallet } = useFundWalletHandler("Mobile menu"); - const smartWallet = isInjectedWallet - ? { address: injectedAddress } - : user?.linkedAccounts.find((account) => account.type === "smart_wallet"); + // Use activeWallet for consistency + const walletForCopy = activeWallet; const { currentStep } = useStep(); @@ -65,7 +80,7 @@ export const MobileDropdown = ({ const { showMfaEnrollmentModal } = useMfaEnrollment(); const handleCopyAddress = () => { - navigator.clipboard.writeText(smartWallet?.address ?? ""); + navigator.clipboard.writeText(walletForCopy?.address ?? ""); toast.success("Address copied to clipboard"); setIsWarningModalOpen(true); }; @@ -90,16 +105,19 @@ export const MobileDropdown = ({ onComplete?: (success: boolean) => void, ) => { await handleFundWallet( - smartWallet?.address ?? "", + walletForCopy?.address ?? "", amount, tokenAddress, onComplete, ); }; + // Get appropriate balance based on migration status const activeBalance = isInjectedWallet ? allBalances.injectedWallet - : allBalances.smartWallet; + : isMigrationComplete + ? allBalances.externalWallet + : allBalances.smartWallet; const handleNetworkSwitchWrapper = (network: Network) => { if (currentStep !== STEPS.FORM) { @@ -140,14 +158,14 @@ export const MobileDropdown = ({ body: JSON.stringify(payload), signal: controller.signal }) - .catch(error => { - if (error.name !== 'AbortError') { - console.warn('Logout tracking failed:', error); - } - }) - .finally(() => { - clearTimeout(timeoutId); - }); + .catch(error => { + if (error.name !== 'AbortError') { + console.warn('Logout tracking failed:', error); + } + }) + .finally(() => { + clearTimeout(timeoutId); + }); }; const handleLogout = async () => { @@ -220,7 +238,7 @@ export const MobileDropdown = ({ getTokenImageUrl={getTokenImageUrl} onTransfer={() => setCurrentView("transfer")} onFund={() => setCurrentView("fund")} - smartWallet={smartWallet} + smartWallet={walletForCopy} handleCopyAddress={handleCopyAddress} isNetworkListOpen={isNetworkListOpen} setIsNetworkListOpen={setIsNetworkListOpen} @@ -289,10 +307,10 @@ export const MobileDropdown = ({ )} - setIsWarningModalOpen(false)} - address={smartWallet?.address ?? ""} + address={walletForCopy?.address ?? ""} /> ); diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index c5fbc468..0ca8bbb4 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -27,6 +27,8 @@ import Image from "next/image"; import { useNetwork } from "../context/NetworksContext"; import { useInjectedWallet } from "../context"; import { useActualTheme } from "../hooks/useActualTheme"; +import { useWallets } from "@privy-io/react-auth"; +import { useMigrationStatus } from "../hooks/useEIP7702Account"; export const Navbar = () => { const [mounted, setMounted] = useState(false); @@ -39,10 +41,25 @@ export const Navbar = () => { const isDark = useActualTheme(); const { ready, authenticated, user } = usePrivy(); + const { wallets } = useWallets(); + const { isMigrationComplete } = useMigrationStatus(); + // Get embedded wallet (EOA) and smart wallet (SCW) + const embeddedWallet = wallets.find( + (wallet) => wallet.walletClientType === "privy" + ); + const smartWallet = user?.linkedAccounts.find( + (account) => account.type === "smart_wallet" + ); + + // Determine active wallet based on migration status + // After migration: show EOA (new wallet with funds) + // Before migration: show SCW (old wallet) const activeWallet = isInjectedWallet ? { address: injectedAddress, type: "injected_wallet" } - : user?.linkedAccounts.find((account) => account.type === "smart_wallet"); + : isMigrationComplete && embeddedWallet + ? { address: embeddedWallet.address, type: "eoa" } + : smartWallet; const { login } = useLogin({ onComplete: async ({ user, isNewUser, loginMethod }) => { @@ -223,9 +240,8 @@ export const Navbar = () => {
Swap diff --git a/app/components/SettingsDropdown.tsx b/app/components/SettingsDropdown.tsx index 3813b232..ddb18225 100644 --- a/app/components/SettingsDropdown.tsx +++ b/app/components/SettingsDropdown.tsx @@ -24,12 +24,16 @@ import { toast } from "sonner"; import { useInjectedWallet } from "../context"; import { useWalletDisconnect } from "../hooks/useWalletDisconnect"; import { CopyAddressWarningModal } from "./CopyAddressWarningModal"; +import { useWallets } from "@privy-io/react-auth"; +import { useMigrationStatus } from "../hooks/useEIP7702Account"; export const SettingsDropdown = () => { const { user, updateEmail } = usePrivy(); + const { wallets } = useWallets(); const { showMfaEnrollmentModal } = useMfaEnrollment(); const [isLoggingOut, setIsLoggingOut] = useState(false); const { isInjectedWallet, injectedAddress } = useInjectedWallet(); + const { isMigrationComplete } = useMigrationStatus(); const [isOpen, setIsOpen] = useState(false); const [isAddressCopied, setIsAddressCopied] = useState(false); @@ -41,10 +45,22 @@ export const SettingsDropdown = () => { handler: () => setIsOpen(false), }); + // Get embedded wallet (EOA) and smart wallet (SCW) + const embeddedWallet = wallets.find( + (wallet) => wallet.walletClientType === "privy" + ); + const smartWallet = user?.linkedAccounts.find( + (account) => account.type === "smart_wallet" + ); + + // Determine active wallet based on migration status + // After migration: show EOA (new wallet with funds) + // Before migration: show SCW (old wallet) const walletAddress = isInjectedWallet ? injectedAddress - : user?.linkedAccounts.find((account) => account.type === "smart_wallet") - ?.address; + : isMigrationComplete && embeddedWallet + ? embeddedWallet.address + : smartWallet?.address; const handleCopyAddress = () => { navigator.clipboard.writeText(walletAddress ?? ""); diff --git a/app/components/WalletDetails.tsx b/app/components/WalletDetails.tsx index 5c012ec1..67f5ea54 100644 --- a/app/components/WalletDetails.tsx +++ b/app/components/WalletDetails.tsx @@ -7,8 +7,9 @@ import { shortenAddress, } from "../utils"; import { useBalance } from "../context/BalanceContext"; -import { usePrivy } from "@privy-io/react-auth"; +import { usePrivy, useWallets } from "@privy-io/react-auth"; import { useNetwork } from "../context/NetworksContext"; +import { useMigrationStatus } from "../hooks/useEIP7702Account"; import { ArrowRight03Icon, Copy01Icon, @@ -55,7 +56,9 @@ export const WalletDetails = () => { const { allBalances, isLoading, refreshBalance } = useBalance(); const { isInjectedWallet, injectedAddress } = useInjectedWallet(); const { user } = usePrivy(); + const { wallets } = useWallets(); const isDark = useActualTheme(); + const { isMigrationComplete } = useMigrationStatus(); // Custom hook for handling wallet funding const { handleFundWallet } = useFundWalletHandler("Wallet details"); @@ -70,15 +73,31 @@ export const WalletDetails = () => { dependencies: [selectedNetwork], }); - // Determine active wallet based on wallet type + // Get embedded wallet (EOA) and smart wallet (SCW) + const embeddedWallet = wallets.find( + (wallet) => wallet.walletClientType === "privy" + ); + const smartWallet = user?.linkedAccounts.find( + (account) => account.type === "smart_wallet" + ); + + // Determine active wallet based on migration status + // After migration: show EOA (new wallet with funds) + // Before migration: show SCW (old wallet) const activeWallet = isInjectedWallet ? { address: injectedAddress } - : user?.linkedAccounts.find((account) => account.type === "smart_wallet"); + : isMigrationComplete && embeddedWallet + ? { address: embeddedWallet.address } + : smartWallet; - // Get appropriate balance based on wallet type + // Get appropriate balance based on migration status + // After migration: use externalWalletBalance (EOA balance) + // Before migration: use smartWalletBalance (SCW balance) const activeBalance = isInjectedWallet ? allBalances.injectedWallet - : allBalances.smartWallet; + : isMigrationComplete + ? allBalances.externalWallet + : allBalances.smartWallet; // Handler for funding wallet with specified amount and token const handleFundWalletClick = async ( diff --git a/app/components/WalletTransferApprovalModal.tsx b/app/components/WalletTransferApprovalModal.tsx index 1455f83c..02c7e1a4 100644 --- a/app/components/WalletTransferApprovalModal.tsx +++ b/app/components/WalletTransferApprovalModal.tsx @@ -11,7 +11,7 @@ import { usePrivy, useWallets } from "@privy-io/react-auth"; import { useSmartWallets } from "@privy-io/react-auth/smart-wallets"; import { formatCurrency, shortenAddress, getNetworkImageUrl, fetchWalletBalance } from "../utils"; import { useActualTheme } from "../hooks/useActualTheme"; -import { useCNGNRate } from "../hooks/useCNGNRate"; +import { getCNGNRateForNetwork } from "../hooks/useCNGNRate"; import WalletMigrationSuccessModal from "./WalletMigrationSuccessModal"; import { type Address, encodeFunctionData, parseAbi, createPublicClient, http, parseUnits } from "viem"; import { toast } from "sonner"; @@ -37,6 +37,7 @@ const WalletTransferApprovalModal: React.FC = const [progress, setProgress] = useState(""); const [allChainBalances, setAllChainBalances] = useState>>({}); const [isFetchingBalances, setIsFetchingBalances] = useState(false); + const [chainRates, setChainRates] = useState>({}); const { allBalances, isLoading } = useBalance(); const { allTokens } = useTokens(); @@ -45,10 +46,6 @@ const WalletTransferApprovalModal: React.FC = const { wallets } = useWallets(); const { client: smartWalletClient } = useSmartWallets(); const isDark = useActualTheme(); - const { rate } = useCNGNRate({ - network: selectedNetwork.chain.name, - dependencies: [selectedNetwork], - }); // Get wallet addresses const smartWallet = user?.linkedAccounts.find(a => a.type === "smart_wallet"); @@ -95,6 +92,32 @@ const WalletTransferApprovalModal: React.FC = fetchAllChainBalances(); }, [isOpen, oldAddress]); + // -------------------- Fetch CNGN rates for each chain -------------------- + useEffect(() => { + if (!isOpen || Object.keys(allChainBalances).length === 0) return; + + const fetchChainRates = async () => { + const rates: Record = {}; + + // Fetch rates for chains that have CNGN tokens + for (const [chainName, balances] of Object.entries(allChainBalances)) { + if (balances.CNGN || balances.cNGN) { + try { + const rate = await getCNGNRateForNetwork(chainName); + rates[chainName] = rate; + } catch (error) { + console.error(`Error fetching CNGN rate for ${chainName}:`, error); + rates[chainName] = null; + } + } + } + + setChainRates(rates); + }; + + fetchChainRates(); + }, [isOpen, allChainBalances]); + // -------------------- Group tokens by chain from all networks -------------------- const tokensByChain = useMemo(() => { const grouped: Record = {}; @@ -112,10 +135,11 @@ const WalletTransferApprovalModal: React.FC = ); if (tokenMeta?.address) { - // Get CNGN rate for this chain if needed - const chainRate = symbol.toUpperCase() === "CNGN" ? rate : undefined; + // Get CNGN rate for this specific chain if needed + const isCNGN = symbol.toUpperCase() === "CNGN"; + const chainRate = isCNGN ? chainRates[chainName] : undefined; let usdValue = balanceNum; - if (chainRate) { + if (isCNGN && chainRate) { usdValue = balanceNum / chainRate; } @@ -144,7 +168,7 @@ const WalletTransferApprovalModal: React.FC = } return grouped; - }, [allChainBalances, allTokens, rate]); + }, [allChainBalances, allTokens, chainRates]); // Flatten for display const tokens = useMemo(() => { @@ -188,6 +212,7 @@ const WalletTransferApprovalModal: React.FC = const allTxHashes: string[] = []; const chains = Object.keys(tokensByChain); + let totalTokensMigrated = 0; // ✅ If tokens exist, process transfers if (hasTokens) { @@ -266,6 +291,7 @@ const WalletTransferApprovalModal: React.FC = }); if (receipt.status === 'success') { + totalTokensMigrated += calls.length; toast.success(`${chainName} migration complete!`, { description: `${calls.length} token(s) transferred to your EOA (gasless)` }); @@ -308,7 +334,7 @@ const WalletTransferApprovalModal: React.FC = toast.success("🎉 Migration Complete!", { description: hasTokens - ? `${allTxHashes.length} token(s) successfully migrated to your EOA` + ? `${totalTokensMigrated} token(s) successfully migrated to your EOA` : "Old wallet deprecated successfully", duration: 5000, }); @@ -336,7 +362,11 @@ const WalletTransferApprovalModal: React.FC = <> {isOpen && ( - + { } : () => onClose()} + className="relative z-50" + > = ({ children }) => { dependencies: [selectedNetwork], }); + // Check migration status + const { isMigrationComplete } = useMigrationStatus(); + const fetchBalances = async () => { setIsLoading(true); @@ -72,28 +76,51 @@ export const BalanceProvider: FC<{ children: ReactNode }> = ({ children }) => { const smartWalletAccount = user?.linkedAccounts.find( (account) => account.type === "smart_wallet", ); + const embeddedWalletAccount = wallets.find( + (wallet) => wallet.walletClientType === "privy" + ); const externalWalletAccount = wallets.find( (account) => account.connectorType === "injected", ); if (client) { - await client.switchChain({ - id: selectedNetwork.chain.id, - }); + try { + await client.switchChain({ + id: selectedNetwork.chain.id, + }); + } catch (error) { + console.warn("Error switching smart wallet chain:", error); + } } - await externalWalletAccount?.switchChain(selectedNetwork.chain.id); - const publicClient = createPublicClient({ chain: selectedNetwork.chain, transport: http( selectedNetwork.chain.id === bsc.id ? "https://bsc-dataseed.bnbchain.org/" - : undefined, + : getRpcUrl(selectedNetwork.chain.name), ), }); - if (smartWalletAccount) { + // After migration, prioritize embedded wallet (EOA) over smart wallet + if (isMigrationComplete && embeddedWalletAccount) { + // Migration complete: Fetch balance for migrated EOA + const result = await fetchWalletBalance( + publicClient, + embeddedWalletAccount.address, + ); + const correctedTotal = calculateCorrectedTotalBalance( + result, + cngnRate, + ); + setExternalWalletBalance({ + ...result, + total: correctedTotal, + }); + // Clear smart wallet balance since it's deprecated + setSmartWalletBalance(null); + } else if (smartWalletAccount) { + // Migration not complete: Fetch balance for old SCW const result = await fetchWalletBalance( publicClient, smartWalletAccount.address, @@ -107,11 +134,17 @@ export const BalanceProvider: FC<{ children: ReactNode }> = ({ children }) => { ...result, total: correctedTotal, }); + setExternalWalletBalance(null); } else { setSmartWalletBalance(null); + setExternalWalletBalance(null); } - if (externalWalletAccount) { + // Handle external injected wallets (separate from embedded wallet) + // Only fetch if it's not the embedded wallet and migration not complete + if (externalWalletAccount && + externalWalletAccount.address !== embeddedWalletAccount?.address && + !isMigrationComplete) { const result = await fetchWalletBalance( publicClient, externalWalletAccount.address, @@ -125,8 +158,6 @@ export const BalanceProvider: FC<{ children: ReactNode }> = ({ children }) => { ...result, total: correctedTotal, }); - } else { - setExternalWalletBalance(null); } setInjectedWalletBalance(null); @@ -184,6 +215,7 @@ export const BalanceProvider: FC<{ children: ReactNode }> = ({ children }) => { injectedReady, injectedAddress, cngnRate, + isMigrationComplete, ]); const allBalances = { diff --git a/app/hooks/useEIP7702Account.ts b/app/hooks/useEIP7702Account.ts index 3983d7b1..8e1b0b3b 100644 --- a/app/hooks/useEIP7702Account.ts +++ b/app/hooks/useEIP7702Account.ts @@ -85,6 +85,50 @@ export function useEIP7702Account(): EIP7702Account { return account; } +// ################################################ +// ########## MIGRATION STATUS HOOK ############### +// ################################################ + +interface MigrationStatus { + isMigrationComplete: boolean; + isLoading: boolean; +} + +/** + * Hook to check if wallet migration is complete + * Returns the migration completion status from the API + */ +export function useMigrationStatus(): MigrationStatus { + const { user } = usePrivy(); + const [isMigrationComplete, setIsMigrationComplete] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + async function checkMigration() { + if (!user?.id) { + setIsLoading(false); + return; + } + + try { + const response = await fetch(`/api/v1/wallets/migration-status?userId=${user.id}`); + if (response.ok) { + const data = await response.json(); + setIsMigrationComplete(data.migrationCompleted ?? false); + } + } catch (error) { + console.error("Error checking migration status:", error); + } finally { + setIsLoading(false); + } + } + + checkMigration(); + }, [user?.id]); + + return { isMigrationComplete, isLoading }; +} + // ################################################ // ########## WALLET MIGRATION STATUS ############# // ################################################ diff --git a/app/pages/TransactionForm.tsx b/app/pages/TransactionForm.tsx index 2f7117a0..d3da757a 100644 --- a/app/pages/TransactionForm.tsx +++ b/app/pages/TransactionForm.tsx @@ -32,6 +32,7 @@ import { useSwapButton } from "../hooks/useSwapButton"; import { fetchKYCStatus } from "../api/aggregator"; import { useCNGNRate } from "../hooks/useCNGNRate"; import { useFundWalletHandler } from "../hooks/useFundWalletHandler"; +import { useMigrationStatus } from "../hooks/useEIP7702Account"; import { useBalance, useInjectedWallet, @@ -64,7 +65,8 @@ export const TransactionForm = ({ const { authenticated, ready, login, user } = usePrivy(); const { wallets } = useWallets(); const { selectedNetwork } = useNetwork(); - const { smartWalletBalance, injectedWalletBalance, isLoading } = useBalance(); + const { smartWalletBalance, externalWalletBalance, injectedWalletBalance, isLoading } = useBalance(); + const { isMigrationComplete, isLoading: isMigrationLoading } = useMigrationStatus(); const { isInjectedWallet, injectedAddress } = useInjectedWallet(); const { allTokens } = useTokens(); @@ -110,13 +112,31 @@ export const TransactionForm = ({ dependencies: [selectedNetwork], }); + // Determine active wallet based on migration status + // After migration: use EOA (new wallet with funds) + // Before migration: use SCW (old wallet) + const embeddedWallet = wallets.find( + (wallet) => wallet.walletClientType === "privy" + ); + const smartWallet = user?.linkedAccounts.find( + (account) => account.type === "smart_wallet" + ); + const activeWallet = isInjectedWallet ? { address: injectedAddress } - : user?.linkedAccounts.find((account) => account.type === "smart_wallet"); - + : isMigrationComplete && embeddedWallet + ? { address: embeddedWallet.address } + : smartWallet; + + // Get appropriate balance based on migration status + // After migration: use externalWalletBalance (EOA balance) + // Before migration: use smartWalletBalance (SCW balance) + // Wait for migration status to load before making decision const activeBalance = isInjectedWallet ? injectedWalletBalance - : smartWalletBalance; + : !isMigrationLoading && isMigrationComplete + ? externalWalletBalance + : smartWalletBalance; const balance = activeBalance?.balances[token] ?? 0; @@ -670,11 +690,10 @@ export const TransactionForm = ({ } }} value={formattedSentAmount} - className={`w-full rounded-xl border-b border-transparent bg-transparent py-2 text-2xl outline-none transition-all placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed dark:placeholder:text-white/30 ${ - authenticated && (amountSent > balance || errors.amountSent) + className={`w-full rounded-xl border-b border-transparent bg-transparent py-2 text-2xl outline-none transition-all placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed dark:placeholder:text-white/30 ${authenticated && (amountSent > balance || errors.amountSent) ? "text-red-500 dark:text-red-500" : "text-neutral-900 dark:text-white/80" - }`} + }`} placeholder="0" title="Enter amount to send" /> @@ -692,16 +711,16 @@ export const TransactionForm = ({
{(errors.amountSent || (authenticated && totalRequired > balance)) && ( - - {errors.amountSent?.message || - (authenticated && totalRequired > balance - ? `Insufficient balance${senderFeeAmount > 0 ? ` (includes ${formatNumberWithCommas(senderFeeAmount)} ${token} fee)` : ""}` - : null)} - - )} + + {errors.amountSent?.message || + (authenticated && totalRequired > balance + ? `Insufficient balance${senderFeeAmount > 0 ? ` (includes ${formatNumberWithCommas(senderFeeAmount)} ${token} fee)` : ""}` + : null)} + + )} {/* Arrow showing swap direction */}
@@ -756,11 +775,10 @@ export const TransactionForm = ({ } }} value={formattedReceivedAmount} - className={`w-full rounded-xl border-b border-transparent bg-transparent py-2 text-2xl outline-none transition-all placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed dark:placeholder:text-white/30 ${ - errors.amountReceived + className={`w-full rounded-xl border-b border-transparent bg-transparent py-2 text-2xl outline-none transition-all placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed dark:placeholder:text-white/30 ${errors.amountReceived ? "text-red-500 dark:text-red-500" : "text-neutral-900 dark:text-white/80" - }`} + }`} placeholder="0" title="Enter amount to receive" /> @@ -809,11 +827,10 @@ export const TransactionForm = ({ formMethods.setValue("memo", e.target.value); }} value={formMethods.watch("memo")} - className={`min-h-11 w-full rounded-xl border border-gray-300 bg-transparent py-2 pl-9 pr-4 text-sm transition-all placeholder:text-text-placeholder focus-within:border-gray-400 focus:outline-none disabled:cursor-not-allowed dark:border-white/20 dark:bg-input-focus dark:placeholder:text-white/30 dark:focus-within:border-white/40 ${ - errors.memo + className={`min-h-11 w-full rounded-xl border border-gray-300 bg-transparent py-2 pl-9 pr-4 text-sm transition-all placeholder:text-text-placeholder focus-within:border-gray-400 focus:outline-none disabled:cursor-not-allowed dark:border-white/20 dark:bg-input-focus dark:placeholder:text-white/30 dark:focus-within:border-white/40 ${errors.memo ? "text-red-500 dark:text-red-500" : "text-text-body dark:text-white/80" - }`} + }`} placeholder="Add description (optional)" maxLength={25} /> diff --git a/app/pages/TransactionPreview.tsx b/app/pages/TransactionPreview.tsx index 19cadd77..218a13a1 100644 --- a/app/pages/TransactionPreview.tsx +++ b/app/pages/TransactionPreview.tsx @@ -24,7 +24,7 @@ import type { } from "../types"; import { primaryBtnClasses, secondaryBtnClasses } from "../components"; import { gatewayAbi } from "../api/abi"; -import { usePrivy } from "@privy-io/react-auth"; +import { usePrivy, useWallets } from "@privy-io/react-auth"; import { useSmartWallets } from "@privy-io/react-auth/smart-wallets"; import { type BaseError, @@ -37,6 +37,7 @@ import { http, } from "viem"; import { useBalance, useInjectedWallet, useStep } from "../context"; +import { useMigrationStatus } from "../hooks/useEIP7702Account"; import { fetchAggregatorPublicKey, saveTransaction } from "../api/aggregator"; import { trackEvent } from "../hooks/analytics/client"; @@ -59,14 +60,16 @@ export const TransactionPreview = ({ }: TransactionPreviewProps) => { const isDark = useActualTheme(); const { user, getAccessToken } = usePrivy(); + const { wallets } = useWallets(); const { client } = useSmartWallets(); const { isInjectedWallet, injectedAddress, injectedProvider, injectedReady } = useInjectedWallet(); + const { isMigrationComplete, isLoading: isMigrationLoading } = useMigrationStatus(); const { selectedNetwork } = useNetwork(); const { allTokens } = useTokens(); const { currentStep, setCurrentStep } = useStep(); - const { refreshBalance, smartWalletBalance, injectedWalletBalance } = + const { refreshBalance, smartWalletBalance, externalWalletBalance, injectedWalletBalance } = useBalance(); const { @@ -101,11 +104,6 @@ export const TransactionPreview = ({ const searchParams = useSearchParams(); - const embeddedWallet = user?.linkedAccounts.find( - (account) => - account.type === "wallet" && account.connectorType === "embedded", - ) as { address: string } | undefined; - const fetchedTokens: Token[] = allTokens[selectedNetwork.chain.name] || []; const tokenAddress = fetchedTokens.find( @@ -120,15 +118,30 @@ export const TransactionPreview = ({ ? { address: injectedAddress, type: "injected_wallet" } : null; + // Determine active wallet based on migration status + // After migration: use EOA (new wallet with funds) + // Before migration: use SCW (old wallet) + const embeddedWallet = wallets.find( + (wallet) => wallet.walletClientType === "privy" + ); const smartWallet = isInjectedWallet ? null : user?.linkedAccounts.find((account) => account.type === "smart_wallet"); - const activeWallet = injectedWallet || smartWallet; + const activeWallet = injectedWallet || + (isMigrationComplete && embeddedWallet + ? { address: embeddedWallet.address, type: "eoa" } + : smartWallet); + // Get appropriate balance based on migration status + // After migration: use externalWalletBalance (EOA balance) + // Before migration: use smartWalletBalance (SCW balance) + // Wait for migration status to load before making decision const balance = injectedWallet ? injectedWalletBalance?.balances[token] || 0 - : smartWalletBalance?.balances[token] || 0; + : !isMigrationLoading && isMigrationComplete + ? externalWalletBalance?.balances[token] || 0 + : smartWalletBalance?.balances[token] || 0; // Calculate sender fee for display and balance check const { @@ -363,6 +376,19 @@ export const TransactionPreview = ({ const handlePaymentConfirmation = async () => { // Check balance including sender fee const totalRequired = amountSent + senderFeeAmount; + + // Debug: Log balance information + console.log("🔍 Balance check:", { + amountSent, + senderFeeAmount, + totalRequired, + balance, + isMigrationComplete, + activeWalletAddress: activeWallet?.address, + externalWalletBalance: externalWalletBalance?.balances[token], + smartWalletBalance: smartWalletBalance?.balances[token], + }); + if (totalRequired > balance) { toast.warning("Low balance. Fund your wallet.", { description: `Insufficient funds. You need ${formatNumberWithCommas(totalRequired)} ${token} (${formatNumberWithCommas(amountSent)} ${token} + ${formatNumberWithCommas(senderFeeAmount)} ${token} fee).`, @@ -388,7 +414,7 @@ export const TransactionPreview = ({ orderId: string; txHash: `0x${string}`; }) => { - if (!embeddedWallet?.address || isSavingTransaction) return; + if (!activeWallet?.address || isSavingTransaction) return; setIsSavingTransaction(true); try { @@ -398,7 +424,7 @@ export const TransactionPreview = ({ } const transaction: TransactionCreateInput = { - walletAddress: embeddedWallet.address, + walletAddress: activeWallet.address, transactionType: "swap", fromCurrency: token, toCurrency: currency, @@ -610,9 +636,8 @@ export const TransactionPreview = ({ ) : ( )}

Create Order

diff --git a/app/utils.ts b/app/utils.ts index c106aa7f..a75161ab 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -344,28 +344,28 @@ export const FALLBACK_TOKENS: { [key: string]: Token[] } = { }, ], Ethereum: [ - { - name: "USD Coin", - symbol: "USDC", - decimals: 6, - address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - imageUrl: "/logos/usdc-logo.svg", - }, - { - name: "Tether USD", - symbol: "USDT", - decimals: 6, - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", - imageUrl: "/logos/usdt-logo.svg", - }, - { + { + name: "USD Coin", + symbol: "USDC", + decimals: 6, + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + imageUrl: "/logos/usdc-logo.svg", + }, + { + name: "Tether USD", + symbol: "USDT", + decimals: 6, + address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + imageUrl: "/logos/usdt-logo.svg", + }, + { name: "cNGN", symbol: "cNGN", decimals: 6, address: "0x17CDB2a01e7a34CbB3DD4b83260B05d0274C8dab", imageUrl: "/logos/cngn-logo.svg", }, -], + ], }; /** From 9805535546fa37bc07129f822498987a38abfa6f Mon Sep 17 00:00:00 2001 From: sundayonah Date: Sat, 7 Feb 2026 17:34:06 +0100 Subject: [PATCH 5/5] feat: update wallet migration logic and enhance component integration - Refactored wallet migration handling across components to utilize the new useShouldUseEOA hook for determining active wallet type. - Updated MobileDropdown, Navbar, SettingsDropdown, WalletDetails, and TransferForm components to reflect changes in wallet selection based on migration status. - Improved balance fetching logic to prioritize embedded wallets post-migration and ensure accurate display of wallet balances. - Enhanced UI elements in WalletMigrationBanner and WalletMigrationModal for better user experience during migration processes. - Added new dependencies and updated configuration files to support recent changes. --- app/api/v1/wallets/deprecate/route.ts | 24 +- app/components/AppLayout.tsx | 7 +- app/components/MigrationZeroBalanceModal.tsx | 145 +++++++++++ app/components/MobileDropdown.tsx | 8 +- app/components/Navbar.tsx | 6 +- app/components/SettingsDropdown.tsx | 14 +- app/components/TransferForm.tsx | 49 ++-- app/components/WalletDetails.tsx | 12 +- app/components/WalletMigrationBanner.tsx | 66 ++--- app/components/WalletMigrationModal.tsx | 122 +++++----- .../WalletMigrationSuccessModal.tsx | 2 +- .../WalletTransferApprovalModal.tsx | 149 +++++++----- app/context/BalanceContext.tsx | 39 ++- app/context/MigrationContext.tsx | 20 +- app/hooks/useEIP7702Account.ts | 227 +++++++++++------- app/hooks/useSmartWalletTransfer.ts | 121 ++++++++-- app/pages/TransactionForm.tsx | 25 +- app/pages/TransactionPreview.tsx | 118 +++++++-- next.config.mjs | 16 +- package.json | 1 + pnpm-lock.yaml | 94 ++++++++ public/images/locked.png | Bin 0 -> 1550 bytes public/images/sent.png | Bin 0 -> 1325 bytes 23 files changed, 884 insertions(+), 381 deletions(-) create mode 100644 app/components/MigrationZeroBalanceModal.tsx create mode 100644 public/images/locked.png create mode 100644 public/images/sent.png diff --git a/app/api/v1/wallets/deprecate/route.ts b/app/api/v1/wallets/deprecate/route.ts index 440659a6..ad1d55b2 100644 --- a/app/api/v1/wallets/deprecate/route.ts +++ b/app/api/v1/wallets/deprecate/route.ts @@ -78,17 +78,23 @@ export const POST = withRateLimit(async (request: NextRequest) => { }); // Step 4: Atomic database operations with rollback on failure - // Mark old wallet as deprecated + // 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") - .update({ - status: "deprecated", - deprecated_at: new Date().toISOString(), - migration_completed: true, - migration_tx_hash: txHash, - }) - .eq("address", oldAddress.toLowerCase()) - .eq("user_id", userId); + .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); diff --git a/app/components/AppLayout.tsx b/app/components/AppLayout.tsx index 150ca037..ddbbf615 100644 --- a/app/components/AppLayout.tsx +++ b/app/components/AppLayout.tsx @@ -39,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)}}` : '{}' - }; + }; `}