diff --git a/.env.example b/.env.example index 2c0adbb9..1f4af733 100644 --- a/.env.example +++ b/.env.example @@ -136,3 +136,5 @@ SANITY_STUDIO_PROJECT_ID=your_project_id_here # Next.js App (client-side) NEXT_PUBLIC_SANITY_DATASET=production NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id_here + +NEXT_PUBLIC_BUNDLER_SERVER_URL=http://localhost:3001 \ No newline at end of file diff --git a/app/api/v1/recipients/route.ts b/app/api/v1/recipients/route.ts index 3e11eb19..612a3824 100644 --- a/app/api/v1/recipients/route.ts +++ b/app/api/v1/recipients/route.ts @@ -124,7 +124,7 @@ export const POST = withRateLimit(async (request: NextRequest) => { }); const body = await request.json(); - const { name, institution, institutionCode, accountIdentifier, type } = + const { name, institution, institutionCode, accountIdentifier, type, currency } = body; // Validate request body @@ -152,6 +152,34 @@ export const POST = withRateLimit(async (request: NextRequest) => { ); } + const trimmedInstitutionCode = String(institutionCode).trim(); + const sanitizedIdentifier = String(accountIdentifier).trim(); + + // Only enforce NUBAN digit-length validation for NGN recipients + if (currency === "NGN") { + const digits = sanitizedIdentifier.replace(/\D/g, ""); + const requiredLen = trimmedInstitutionCode === "SAFAKEPC" ? 6 : 10; + if (digits.length !== requiredLen) { + trackApiError( + request, + "/api/v1/recipients", + "POST", + new Error("Invalid account identifier length"), + 400, + ); + return NextResponse.json( + { + success: false, + error: + requiredLen === 10 + ? "Please enter a valid 10-digit account number." + : "Please enter a valid 6-digit account number.", + }, + { status: 400 }, + ); + } + } + // Validate type if (!["bank", "mobile_money"].includes(type)) { trackApiError( @@ -199,7 +227,7 @@ export const POST = withRateLimit(async (request: NextRequest) => { } } - // Insert recipient (upsert on unique constraint) + // Insert recipient (upsert on unique constraint) - store sanitized digits so DB has consistent format const { data, error } = await supabaseAdmin .from("saved_recipients") .upsert( @@ -208,8 +236,8 @@ export const POST = withRateLimit(async (request: NextRequest) => { normalized_wallet_address: walletAddress, name: name.trim(), institution: institution.trim(), - institution_code: institutionCode.trim(), - account_identifier: accountIdentifier.trim(), + institution_code: trimmedInstitutionCode, + account_identifier: currency === "NGN" ? sanitizedIdentifier.replace(/\D/g, "") : sanitizedIdentifier, type, }, { @@ -244,14 +272,14 @@ export const POST = withRateLimit(async (request: NextRequest) => { const responseTime = Date.now() - startTime; trackApiResponse("/api/v1/recipients", "POST", 200, responseTime, { wallet_address: walletAddress, - institution_code: institutionCode, + institution_code: trimmedInstitutionCode, type, }); // Track business event trackBusinessEvent("Recipient Saved", { wallet_address: walletAddress, - institution_code: institutionCode, + institution_code: trimmedInstitutionCode, type, }); diff --git a/app/api/v1/wallets/migration-status/route.ts b/app/api/v1/wallets/migration-status/route.ts index c8d66332..6ab51069 100644 --- a/app/api/v1/wallets/migration-status/route.ts +++ b/app/api/v1/wallets/migration-status/route.ts @@ -39,7 +39,6 @@ export async function GET(request: NextRequest) { .single(); if (error) { - // PGRST116 = no rows found — user has no smart wallet, not an error if (error.code === "PGRST116") { return NextResponse.json({ migrationCompleted: false, @@ -47,13 +46,10 @@ export async function GET(request: NextRequest) { hasSmartWallet: false, }); } - - // PGRST205 = table not found in schema cache — migration not applied 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: "schema_unavailable", @@ -61,7 +57,6 @@ export async function GET(request: NextRequest) { error: "Database schema not ready", }); } - console.error("Database query error:", error); return NextResponse.json({ migrationCompleted: false, diff --git a/app/components/TransferForm.tsx b/app/components/TransferForm.tsx index 02bf8b73..5c5b57e5 100644 --- a/app/components/TransferForm.tsx +++ b/app/components/TransferForm.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useState, useRef } from "react"; import { useForm } from "react-hook-form"; import { usePrivy, useWallets } from "@privy-io/react-auth"; -import { useSmartWallets } from "@privy-io/react-auth/smart-wallets"; import { useShouldUseEOA, useWalletMigrationStatus } from "../hooks/useEIP7702Account"; import { useNetwork } from "../context/NetworksContext"; import { useBalance, useTokens } from "../context"; @@ -44,7 +43,6 @@ export const TransferForm: React.FC<{ }> = ({ onClose, onSuccess, showBackButton = false, setCurrentView }) => { const searchParams = useSearchParams(); const { selectedNetwork } = useNetwork(); - const { client } = useSmartWallets(); const { user, getAccessToken } = usePrivy(); const { wallets } = useWallets(); const shouldUseEOA = useShouldUseEOA(); @@ -120,12 +118,12 @@ export const TransferForm: React.FC<{ getTxExplorerLink, error, } = useSmartWalletTransfer({ - client: client ?? null, selectedNetwork: transferNetwork, user, supportedTokens: fetchedTokens, getAccessToken, refreshBalance, + onRequireMigration: () => setIsMigrationModalOpen(true), }); useEffect(() => { diff --git a/app/components/WalletMigrationModal.tsx b/app/components/WalletMigrationModal.tsx index 8450af1d..8bc94201 100644 --- a/app/components/WalletMigrationModal.tsx +++ b/app/components/WalletMigrationModal.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import Image from "next/image"; import { AnimatePresence, motion } from "framer-motion"; import { Dialog, DialogPanel } from "@headlessui/react"; @@ -17,21 +17,32 @@ const WalletMigrationModal: React.FC = ({ }) => { const [showTransferModal, setShowTransferModal] = useState(false); - const handleApproveMigration = () => { - onClose(); + const handleApproveMigration = ( + event: React.MouseEvent + ) => { + // Prevent click-through where the same click immediately closes the next modal. + event.preventDefault(); + event.stopPropagation(); setTimeout(() => { setShowTransferModal(true); - }, 300); + }, 50); }; const handleCloseTransferModal = () => { setShowTransferModal(false); + onClose(); }; + useEffect(() => { + if (!isOpen && showTransferModal) { + setShowTransferModal(false); + } + }, [isOpen, showTransferModal]); + return ( <> - {isOpen && ( + {isOpen && !showTransferModal && ( [n.chain.name, n.chain]) ); +// Biconomy v2 ECDSA module address (same across chains). Signature must be ABI-encoded as (bytes moduleSig, address moduleAddress) per blog. +const BICONOMY_V2_ECDSA_MODULE = "0x0000001c5b32F37F5beA87BDD5374eB2aC54eA8e" as const; +// Ignore tiny balances in UI and migration. 1e-6 token + $0.001 min value. +const DUST_USD_THRESHOLD = 0.001; +const DUST_TOKEN_DECIMALS = 6; + +function getDustRawThreshold(decimals: number): bigint { + if (decimals <= DUST_TOKEN_DECIMALS) return BigInt(1); + return BigInt(10) ** BigInt(decimals - DUST_TOKEN_DECIMALS); +} + interface WalletTransferApprovalModalProps { isOpen: boolean; onClose: () => void; @@ -150,6 +168,12 @@ const WalletTransferApprovalModal: React.FC = ); if (tokenMeta?.address) { + const rawAmount = allChainRawBalances[chainName]?.[symbol] ?? BigInt(0); + const decimals = tokenMeta.decimals || 18; + const dustRawThreshold = getDustRawThreshold(decimals); + // Hide tiny balances (dust) before showing migration modal. + if (rawAmount > BigInt(0) && rawAmount < dustRawThreshold) continue; + // Get CNGN rate for this specific chain if needed const isCNGN = symbol.toUpperCase() === "CNGN"; const chainRate = isCNGN ? chainRates[chainName] : undefined; @@ -157,8 +181,7 @@ const WalletTransferApprovalModal: React.FC = if (isCNGN && chainRate) { usdValue = balanceNum / chainRate; } - - const rawAmount = allChainRawBalances[chainName]?.[symbol] ?? BigInt(0); + if (usdValue < DUST_USD_THRESHOLD) continue; const token = { id: `${chainName}-${symbol}`, @@ -171,7 +194,7 @@ const WalletTransferApprovalModal: React.FC = value: `${usdValue.toFixed(2)}`, icon: `/logos/${symbol.toLowerCase()}-logo.svg`, address: tokenMeta.address, - decimals: tokenMeta.decimals || 18, + decimals, }; if (!grouped[chainName]) { @@ -228,94 +251,257 @@ const WalletTransferApprovalModal: React.FC = setProgress(hasTokens ? "Initializing migration..." : "Deprecating old wallet..."); try { - const accessToken = await getAccessToken(); - if (!accessToken) throw new Error("Failed to get access token"); + const getAccessTokenWithRetry = async (): Promise => { + // Token can be temporarily unavailable right after auth/wallet operations. + const tryGet = async () => (await getAccessToken()) ?? ""; + let token = await tryGet(); + if (token) return token; + await new Promise((resolve) => setTimeout(resolve, 400)); + token = await tryGet(); + if (token) return token; + await new Promise((resolve) => setTimeout(resolve, 700)); + token = await tryGet(); + if (!token) { + throw new Error("Failed to get access token. Please re-login and try again."); + } + return token; + }; const allTxHashes: string[] = []; const chains = Object.keys(tokensByChain); let totalTokensMigrated = 0; - // ✅ If tokens exist, process transfers + // ✅ If tokens exist: (1) upgrade SCW to Nexus via upgrade-server, then (2) use MEE to transfer. 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."); + const meeApiKey = config.biconomyMeeApiKey; + const bundlerServerUrl = config.bundlerServerUrl.trim().replace(/\/+$/, ""); + if (!meeApiKey) { + throw new Error("Biconomy MEE API key not configured. Set NEXT_PUBLIC_BICONOMY_MEE_API_KEY."); + } + if (!bundlerServerUrl) { + throw new Error("Upgrade server URL not configured. Set NEXT_PUBLIC_BUNDLER_SERVER_URL."); + } + try { + const parsed = new URL(bundlerServerUrl); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("invalid protocol"); + } + } catch { + throw new Error("Invalid NEXT_PUBLIC_BUNDLER_SERVER_URL. Use a full URL"); + } + if (!embeddedWallet || !oldAddress) { + throw new Error("Wallet not available. Please ensure you are logged in."); } + // --- For each chain: check nexus status, upgrade if needed, then transfer via MEE --- 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...`); + if (!chain || !newAddress || !oldAddress) { + 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, - }); + setProgress(`Preparing migration on ${chainName}...`); + await embeddedWallet.switchChain(chain.id); + const chainProvider = await embeddedWallet.getEthereumProvider(); + const chainRpcUrl = getRpcUrl(chain.name); + + setProgress(`Checking wallet version on ${chainName}...`); + const statusUrl = `${bundlerServerUrl}/is-nexus?smartAccountAddress=${encodeURIComponent(oldAddress)}&chainId=${chain.id}${chainRpcUrl ? `&rpcUrl=${encodeURIComponent(chainRpcUrl)}` : ""}`; + const statusRes = await fetch(statusUrl); + if (!statusRes.ok) { + const errText = await statusRes.text(); + throw new Error(`Nexus check failed on ${chainName}: ${errText || statusRes.statusText}`); + } + const statusData = (await statusRes.json()) as { + isNexus?: boolean; + deployed?: boolean; + accountId?: string; + reason?: string; + }; + const alreadyNexus = Boolean(statusData?.isNexus); + const isDeployed = statusData?.deployed !== false; + + if (!alreadyNexus) { + // If account is not deployed on this chain, deploy via Privy first (Privy's initCode). Then our Nexus upgrade will see a deployed account and use initCode '0x'. + if (!isDeployed && smartWalletClient) { + setProgress(`Deploying wallet on ${chainName} ...`); + try { + await smartWalletClient.switchChain({ id: chain.id }); + const deployHash = await smartWalletClient.sendTransaction({ + to: oldAddress as Address, + value: BigInt(0), + data: "0x", + }); + if (deployHash) allTxHashes.push(deployHash); + } catch (deployErr) { + const msg = deployErr instanceof Error ? deployErr.message : String(deployErr); + throw new Error(`Deploy wallet on ${chainName} failed: ${msg}`); + } + } + + setProgress(`Upgrading wallet to Nexus on ${chainName}...`); + const genRes = await fetch(`${bundlerServerUrl}/generate-userop`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + smartAccountAddress: oldAddress, + ownerAddress: embeddedWallet.address, + chainId: chain.id, + rpcUrl: chainRpcUrl, + }), + }); + if (!genRes.ok) { + const errText = await genRes.text(); + let msg = errText || genRes.statusText; + try { + const j = JSON.parse(errText) as { error?: string }; + if (j?.error) msg = j.error; + } catch { + /* use errText as-is */ + } + throw new Error(`Upgrade prepare failed on ${chainName}: ${msg}`); + } + const { userOp: unsignedUserOp, userOpHash } = (await genRes.json()) as { + userOp: Record & { signature?: string }; + userOpHash: string; + }; + if (!unsignedUserOp || !userOpHash) { + throw new Error(`Invalid upgrade response on ${chainName} (missing userOp or userOpHash)`); + } + + setProgress(`Signing upgrade on ${chainName}...`); + const rawSignature = (await chainProvider.request({ + method: "personal_sign", + params: [userOpHash, embeddedWallet.address], + })) as string; + const signature = encodeAbiParameters( + [{ type: "bytes" }, { type: "address" }], + [rawSignature as `0x${string}`, BICONOMY_V2_ECDSA_MODULE as Address] + ); + const signedUserOp = { ...unsignedUserOp, signature }; + + setProgress(`Submitting upgrade on ${chainName}...`); + const execRes = await fetch(`${bundlerServerUrl}/execute`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userOp: signedUserOp, + smartAccountAddress: oldAddress, + chainId: chain.id, + rpcUrl: chainRpcUrl, + }), + }); + if (!execRes.ok) { + const errText = await execRes.text(); + throw new Error(`Upgrade execute failed on ${chainName}: ${errText || execRes.statusText}`); + } + const execData = (await execRes.json()) as { transactionHash?: string }; + if (execData.transactionHash) allTxHashes.push(execData.transactionHash); + // toast.success(`Wallet upgraded to Nexus on ${chainName}`); + } - const publicClient = createPublicClient({ - chain, - transport: getTransportForChain(chain), + const nexusAccount = await toMultichainNexusAccount({ + chainConfigurations: [ + { + chain, + transport: http(chainRpcUrl), + version: getMEEVersion(MEEVersion.V2_1_0), + accountAddress: oldAddress as `0x${string}`, + }, + ], + signer: chainProvider, }); - // ✅ 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 transferData = encodeFunctionData({ - abi: parseAbi(["function transfer(address to, uint256 amount) returns (bool)"]), - functionName: "transfer", - args: [newAddress as Address, token.rawAmount], - }); - - return { - to: token.address as `0x${string}`, - data: transferData as `0x${string}`, - value: BigInt(0), - }; + const meeClient = await createMeeClient({ + account: nexusAccount, + apiKey: meeApiKey, }); - if (calls.length === 0) { + // Transfer full raw balance so users don't lose meaningful dust (e.g. 50 -> 49.995). + const instructions = await Promise.all( + chainTokens.map((token) => { + const raw = token.rawAmount as bigint; + return nexusAccount.buildComposable({ + type: "default", + data: { + abi: erc20Abi, + chainId: chain.id, + to: token.address as `0x${string}`, + functionName: "transfer", + args: [newAddress as Address, raw], + }, + }); + }) + ); + + if (instructions.length === 0) { console.warn(`No tokens to migrate on ${chainName}`); continue; } - setProgress(`Transferring ${calls.length} token${calls.length === 1 ? '' : 's'} on ${chainName}.`); - - // ✅ 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...`); + setProgress(`Submitting transfer on ${chainName}...`); - const receipt = await publicClient.waitForTransactionReceipt({ - hash: txHash as `0x${string}`, - confirmations: 1, + const { hash: supertxHash } = await meeClient.execute({ + sponsorship: true, + instructions, }); - if (receipt.status === 'success') { - totalTokensMigrated += calls.length; - toast.success(`${chainName} migration complete!`, { - description: `${calls.length} token${calls.length === 1 ? '' : 's'} transferred to your new wallet.` - }); - } else { - throw new Error(`Transaction failed for ${chainName}`); + setProgress(`Confirming on ${chainName}... (may take 2–5 min)`); + + const MEE_WAIT_TIMEOUT_MS = 120_000; // 2 min max so we don't hang + const receipt = await Promise.race([ + meeClient.waitForSupertransactionReceipt({ + hash: supertxHash, + waitForReceipts: true, + confirmations: 1, + }), + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `Confirmation is taking longer than expected. You can check status at https://meescan.biconomy.io/details/${supertxHash} or try again.` + ) + ), + MEE_WAIT_TIMEOUT_MS + ) + ), + ]); + + // SDK throws on FAILED/MINED_FAIL; double-check receipt when we have it + const status = (receipt as { transactionStatus?: string; message?: string }).transactionStatus; + const statusMessage = (receipt as { message?: string }).message; + if (status === "FAILED" || status === "MINED_FAIL") { + throw new Error(statusMessage || "Transaction failed on chain"); } + const onChainTxHash = + (receipt.receipts?.[0] as { transactionHash?: `0x${string}` } | undefined)?.transactionHash ?? + (receipt.userOps?.[0] as { executionData?: `0x${string}` } | undefined)?.executionData ?? + supertxHash; + allTxHashes.push(onChainTxHash as string); + + totalTokensMigrated += instructions.length; + toast.success(`${chainName} migration complete!`, { + description: `${instructions.length} token${instructions.length === 1 ? "" : "s"} transferred to your new wallet.`, + }); } catch (chainError) { - const errorMsg = chainError instanceof Error ? chainError.message : 'Unknown error'; + const rawMsg = chainError instanceof Error ? chainError.message : "Unknown error"; + const isDeadlineOrRevert = + /deadline limit exceeded|revert|transaction failed/i.test(rawMsg); + const description = isDeadlineOrRevert + ? "Simulation reverted or timed out. Try again, or transfer a slightly smaller amount. You can also check status at meescan.biconomy.io." + : rawMsg; + setError(rawMsg); toast.error(`Failed to migrate ${chainName}`, { - description: errorMsg + description, }); } } @@ -326,6 +512,7 @@ const WalletTransferApprovalModal: React.FC = } setProgress("Finalizing migration..."); + const accessToken = await getAccessTokenWithRetry(); const response = await fetch("/api/v1/wallets/deprecate", { method: "POST", @@ -343,7 +530,8 @@ const WalletTransferApprovalModal: React.FC = }); if (!response.ok) { - throw new Error("Failed to update backend"); + const errText = await response.text().catch(() => ""); + throw new Error(`Failed to update backend${errText ? `: ${errText}` : ""}`); } toast.success("🎉 Migration Complete!", { diff --git a/app/components/recipient/RecipientDetailsForm.tsx b/app/components/recipient/RecipientDetailsForm.tsx index 3ac117d7..63d60fcf 100644 --- a/app/components/recipient/RecipientDetailsForm.tsx +++ b/app/components/recipient/RecipientDetailsForm.tsx @@ -243,20 +243,35 @@ export const RecipientDetailsForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedInstitution, isManualEntry]); - // Fetch recipient name based on institution and account identifier + // Fetch recipient name based on institution and account identifier (only enforce digit-length for NGN) useEffect(() => { let timeoutId: NodeJS.Timeout; const getRecipientName = async () => { if (!isManualEntry) return; - if ( - !institution || - !accountIdentifier || - accountIdentifier.toString().length < - (selectedInstitution?.code === "SAFAKEPC" ? 6 : 10) - ) + const isNGN = currency === "NGN"; + const digits = String(accountIdentifier ?? "").replace(/\D/g, ""); + const requiredLen = selectedInstitution?.code === "SAFAKEPC" ? 6 : 10; + + if (!institution || !accountIdentifier) { + setRecipientNameError(""); return; + } + + if (isNGN && digits.length !== requiredLen) { + if (digits.length > 0) { + setRecipientNameError( + requiredLen === 10 + ? "Please enter a valid 10-digit account Number." + : "Invalid account number. Please enter a 6-digit account number.", + ); + } else { + setRecipientNameError(""); + } + return; + } + setRecipientNameError(""); setIsFetchingRecipientName(true); setValue("recipientName", ""); @@ -380,19 +395,28 @@ export const RecipientDetailsForm = ({ - {/* Account number */} + {/* Account number - NUBAN is 10 digits; SAFAKEPC uses 6 digits */}
{ + if (currency !== "NGN") return true; + const digits = String(value ?? "").replace(/\D/g, ""); + const requiredLen = selectedInstitution?.code === "SAFAKEPC" ? 6 : 10; + if (digits.length !== requiredLen) { + return requiredLen === 10 + ? "Please enter a valid 10-digit account Number." + : "Invalid account number. Please enter a 6-digit account number."; + } + return true; }, onChange: () => setIsManualEntry(true), })} diff --git a/app/context/MigrationContext.tsx b/app/context/MigrationContext.tsx index 8da9ae96..a0b60dbb 100644 --- a/app/context/MigrationContext.tsx +++ b/app/context/MigrationContext.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useState } from "react"; import { useWalletMigrationStatus } from "../hooks/useEIP7702Account"; import { WalletMigrationBanner } from "../components/WalletMigrationBanner"; import { MigrationZeroBalanceModal } from "../components/MigrationZeroBalanceModal"; @@ -19,6 +20,11 @@ export const MigrationBannerWrapper = () => { const { user } = usePrivy(); const { isInjectedWallet } = useInjectedWallet(); const { needsMigration, isChecking, showZeroBalanceMessage, isRemainingFundsMigration, refetchMigrationStatus } = useWalletMigrationStatus(); + const [stableVisibility, setStableVisibility] = useState({ + needsMigration: false, + showZeroBalanceMessage: false, + isRemainingFundsMigration: false, + }); const walletAddress = user?.wallet?.address; @@ -29,12 +35,30 @@ export const MigrationBannerWrapper = () => { const canShowMigrationModal = isInjectedWallet || dismissedViaStore || dismissedViaStorage; - if (isChecking) return null; - if (needsMigration && canShowMigrationModal) return ; - if (showZeroBalanceMessage && canShowMigrationModal) { + // Keep the last stable visibility while loading to avoid unmounting migration modals mid-flow. + useEffect(() => { + if (!isChecking) { + setStableVisibility({ + needsMigration, + showZeroBalanceMessage, + isRemainingFundsMigration, + }); + } + }, [isChecking, needsMigration, showZeroBalanceMessage, isRemainingFundsMigration]); + + const visibleNeedsMigration = isChecking ? stableVisibility.needsMigration : needsMigration; + const visibleShowZeroBalance = isChecking ? stableVisibility.showZeroBalanceMessage : showZeroBalanceMessage; + const visibleRemainingFundsMigration = isChecking + ? stableVisibility.isRemainingFundsMigration + : isRemainingFundsMigration; + + if (visibleNeedsMigration && canShowMigrationModal) { + return ; + } + if (visibleShowZeroBalance && canShowMigrationModal) { return ( ); diff --git a/app/hooks/useEIP7702Account.ts b/app/hooks/useEIP7702Account.ts index 02eea050..69c21cb8 100644 --- a/app/hooks/useEIP7702Account.ts +++ b/app/hooks/useEIP7702Account.ts @@ -13,6 +13,13 @@ import { useBalance } from "../context/BalanceContext"; import { useMigrationStatus, triggerMigrationStatusRefetch } from "../context/MigrationStatusContext"; import config from "../lib/config"; +// Treat tiny residual balances as zero for migration UX decisions. +const MIGRATION_DUST_USD_THRESHOLD = 0.001; + +function hasMeaningfulBalance(value: number | null | undefined): boolean { + return Number(value ?? 0) >= MIGRATION_DUST_USD_THRESHOLD; +} + // ################################################ // ########## EIP-7702 LIB HELPERS ################ // ################################################ @@ -97,7 +104,7 @@ export function useShouldUseEOA(): boolean { // While migration status or balances are loading, hold the last value. // Default to true (EOA) so we never briefly flash the old SCW address/balance. if (isMigrationLoading || isBalanceLoading) return lastValueRef.current ?? true; - const value = smartWalletCrossChainTotal === 0; + const value = !hasMeaningfulBalance(smartWalletCrossChainTotal); lastValueRef.current = value; return value; } @@ -144,17 +151,17 @@ export function useWalletMigrationStatus(): WalletMigrationStatus { if (isMigrationLoading || isBalanceLoading) return; if (isMigrationComplete) { - setNeedsMigration(smartWalletRemainingTotal > 0); + setNeedsMigration(hasMeaningfulBalance(smartWalletRemainingTotal)); setShowZeroBalanceMessage(false); } else { - const hasBalance = crossChainTotal > 0; + const hasBalance = hasMeaningfulBalance(crossChainTotal); setNeedsMigration(hasBalance); setShowZeroBalanceMessage(!hasBalance); } }, [authenticated, user, hasSmartWallet, isMigrationComplete, isMigrationLoading, crossChainTotal, isBalanceLoading, smartWalletRemainingTotal]); const isRemainingFundsMigration = - isMigrationComplete === true && smartWalletRemainingTotal > 0; + isMigrationComplete === true && hasMeaningfulBalance(smartWalletRemainingTotal); return { needsMigration, isChecking, showZeroBalanceMessage, isRemainingFundsMigration, refetchMigrationStatus: refetch }; } diff --git a/app/hooks/useSmartWalletTransfer.ts b/app/hooks/useSmartWalletTransfer.ts index 92a4857d..7853bebe 100644 --- a/app/hooks/useSmartWalletTransfer.ts +++ b/app/hooks/useSmartWalletTransfer.ts @@ -1,5 +1,5 @@ import { useState, useCallback } from "react"; -import { encodeFunctionData, erc20Abi, parseUnits, http } from "viem"; +import { erc20Abi, parseUnits, http } from "viem"; import { toast } from "sonner"; import { getExplorerLink, getRpcUrl } from "../utils"; import { saveTransaction } from "../api/aggregator"; @@ -14,23 +14,15 @@ import { getMEEVersion, MEEVersion, } from "@biconomy/abstractjs"; - -interface SmartWalletClient { - sendTransaction: (args: { - to: `0x${string}`; - data: `0x${string}`; - value: bigint; - }) => Promise; - switchChain: (args: { id: number }) => Promise; -} +import config from "../lib/config"; interface UseSmartWalletTransferParams { - client: SmartWalletClient | null; selectedNetwork: { chain: Network["chain"] }; user: User | null; supportedTokens: Token[]; getAccessToken: () => Promise; refreshBalance?: () => void; + onRequireMigration?: () => void; } interface TransferArgs { @@ -59,12 +51,12 @@ interface UseSmartWalletTransferReturn { * @returns {UseSmartWalletTransferReturn} - State and transfer function */ export function useSmartWalletTransfer({ - client, selectedNetwork, user, supportedTokens, getAccessToken, refreshBalance, + onRequireMigration, }: UseSmartWalletTransferParams): UseSmartWalletTransferReturn { const shouldUseEOA = useShouldUseEOA(); const { wallets } = useWallets(); @@ -119,133 +111,104 @@ export function useSmartWalletTransfer({ (t) => t.symbol.toUpperCase() === searchToken, ); - // Native token transfer logic (ETH, BNB, etc.) - if (tokenData?.isNative && tokenData?.address === "") { - const value = BigInt(Math.floor(amount * 1e18)); - const hash = await client?.sendTransaction({ - to: recipientAddress as `0x${string}`, - value, - data: "0x" as `0x${string}`, - }); - if (!hash) throw new Error("No transaction hash returned"); - const txhash = hash as unknown as string; - setTxHash(txhash); - setTxNetworkName(selectedNetwork.chain.name); - setTransferAmount(amount.toString()); - setTransferToken(token); - setIsSuccess(true); + if (!shouldUseEOA) { + onRequireMigration?.(); setIsLoading(false); - toast.success( - `${amount.toString()} ${token} successfully transferred`, - ); - trackEvent("Transfer completed", { - Amount: amount, - "Send token": token, - "Recipient address": recipientAddress, - Network: selectedNetwork.chain.name, - "Transaction hash": hash, - "Transfer date": new Date().toISOString(), - }); - await saveTransferTransaction({ - txHash: txhash, - recipientAddress, - amount, - token, - }); - if (resetForm) resetForm(); - if (refreshBalance) refreshBalance(); + setIsSuccess(false); + setError(""); return; } - // ERC-20 token transfer logic - const tokenAddress = tokenData?.address as `0x${string}` | undefined; - const tokenDecimals = tokenData?.decimals; - if (!tokenAddress || tokenDecimals === undefined) { - const error = `Token data not found for ${token}.`; - setError(error); - trackEvent("Transfer failed", { - Amount: amount, - "Send token": token, - "Recipient address": recipientAddress, - Network: selectedNetwork.chain.name, - "Reason for failure": error, - "Transfer date": new Date().toISOString(), - }); - throw new Error( - `Token data not found for ${token}. Available tokens: ${availableTokens.map((t) => t.symbol).join(", ")}`, - ); - } - let hash: `0x${string}`; - if (shouldUseEOA && embeddedWallet) { - // EIP-7702 + Biconomy MEE path (migrated EOA or 0-balance SCW) - const chain = selectedNetwork.chain; - const chainId = chain.id; - await embeddedWallet.switchChain(chainId); - const provider = await embeddedWallet.getEthereumProvider(); - const authorization = await signBiconomyAuthorization(chainId); - const nexusAccount = await toMultichainNexusAccount({ - chainConfigurations: [ - { - chain, - transport: http(getRpcUrl(selectedNetwork.chain.name)), - version: getMEEVersion(MEEVersion.V2_1_0), - accountAddress: embeddedWallet.address as `0x${string}`, - }, - ], - signer: provider, - }); - const meeClient = await createMeeClient({ - account: nexusAccount, - apiKey: process.env.NEXT_PUBLIC_BICONOMY_PAYMASTER_KEY ?? "", - }); - const transferInstruction = await nexusAccount.buildComposable({ - type: "default", - data: { - abi: erc20Abi, - chainId, - to: tokenAddress, - functionName: "transfer", - args: [ - recipientAddress as `0x${string}`, - parseUnits(amount.toString(), tokenDecimals), - ], + if (!embeddedWallet) { + throw new Error("Embedded wallet not ready. Please reconnect and try again."); + } + // EIP-7702 + Biconomy MEE path (migrated EOA or 0-balance SCW) + const chain = selectedNetwork.chain; + const chainId = chain.id; + await embeddedWallet.switchChain(chainId); + const provider = await embeddedWallet.getEthereumProvider(); + const authorization = await signBiconomyAuthorization(chainId); + const nexusAccount = await toMultichainNexusAccount({ + chainConfigurations: [ + { + chain, + transport: http(getRpcUrl(selectedNetwork.chain.name)), + version: getMEEVersion(MEEVersion.V2_1_0), + accountAddress: embeddedWallet.address as `0x${string}`, }, - }); - const result = await meeClient.execute({ - authorizations: [authorization], - delegate: true, - sponsorship: true, - instructions: [transferInstruction], - }); - const receipt = await meeClient.waitForSupertransactionReceipt({ - hash: result.hash, - waitForReceipts: true, - }); - const onChainTxHash = - (receipt.receipts?.[0] as { transactionHash?: `0x${string}` } | undefined) - ?.transactionHash ?? - (receipt.userOps?.[0] as { executionData?: `0x${string}` } | undefined) - ?.executionData; - hash = (onChainTxHash ?? result.hash) as `0x${string}`; - } else { - // Smart wallet path (pre-migration) - if (!client) throw new Error("Wallet not ready"); - await client.switchChain({ id: selectedNetwork.chain.id }); - hash = (await client.sendTransaction({ - to: tokenAddress, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: "transfer", - args: [ - recipientAddress as `0x${string}`, - parseUnits(amount.toString(), tokenDecimals), - ], - }) as `0x${string}`, - value: BigInt(0), - })) as `0x${string}`; + ], + signer: provider, + }); + const meeApiKey = config.biconomyMeeApiKey; + if (!meeApiKey) { + throw new Error("Biconomy MEE API key not configured. Set NEXT_PUBLIC_BICONOMY_MEE_API_KEY."); } + const meeClient = await createMeeClient({ + account: nexusAccount, + apiKey: meeApiKey, + }); + + const transferInstruction = + tokenData?.isNative && tokenData?.address === "" + ? await nexusAccount.buildComposable({ + type: "nativeTokenTransfer", + data: { + chainId, + to: recipientAddress as `0x${string}`, + value: parseUnits(amount.toString(), 18), + }, + }) + : await (async () => { + const tokenAddress = tokenData?.address as `0x${string}` | undefined; + const tokenDecimals = tokenData?.decimals; + if (!tokenAddress || tokenDecimals === undefined) { + const error = `Token data not found for ${token}.`; + setError(error); + trackEvent("Transfer failed", { + Amount: amount, + "Send token": token, + "Recipient address": recipientAddress, + Network: selectedNetwork.chain.name, + "Reason for failure": error, + "Transfer date": new Date().toISOString(), + }); + throw new Error( + `Token data not found for ${token}. Available tokens: ${availableTokens.map((t) => t.symbol).join(", ")}`, + ); + } + return nexusAccount.buildComposable({ + type: "default", + data: { + abi: erc20Abi, + chainId, + to: tokenAddress, + functionName: "transfer", + args: [ + recipientAddress as `0x${string}`, + parseUnits(amount.toString(), tokenDecimals), + ], + }, + }); + })(); + + const result = await meeClient.execute({ + authorizations: [authorization], + delegate: true, + sponsorship: true, + instructions: [transferInstruction], + }); + const receipt = await meeClient.waitForSupertransactionReceipt({ + hash: result.hash, + waitForReceipts: true, + }); + const onChainTxHash = + (receipt.receipts?.[0] as { transactionHash?: `0x${string}` } | undefined) + ?.transactionHash ?? + (receipt.userOps?.[0] as { executionData?: `0x${string}` } | undefined) + ?.executionData; + hash = (onChainTxHash ?? result.hash) as `0x${string}`; if (!hash) throw new Error("No transaction hash returned"); setTxHash(hash); @@ -304,7 +267,6 @@ export function useSmartWalletTransfer({ }, // eslint-disable-next-line react-hooks/exhaustive-deps [ - client, selectedNetwork, user, supportedTokens, @@ -313,6 +275,7 @@ export function useSmartWalletTransfer({ shouldUseEOA, embeddedWallet, signBiconomyAuthorization, + onRequireMigration, ], ); diff --git a/app/lib/config.ts b/app/lib/config.ts index 47792f4e..326622f1 100644 --- a/app/lib/config.ts +++ b/app/lib/config.ts @@ -15,8 +15,13 @@ const config: Config = { process.env.NEXT_PUBLIC_BLOCKFEST_END_DATE || "2025-10-11T23:59:00+01:00", biconomyNexusV120: process.env.NEXT_PUBLIC_BICONOMY_NEXUS_V120 || "0x000000004f43c49e93c970e84001853a70923b03", - biconomyPaymasterKey: - process.env.NEXT_PUBLIC_BICONOMY_PAYMASTER_KEY || "", + /** MEE API key for Biconomy Supertransaction API (sponsored execution). Replaces deprecated paymaster. */ + biconomyMeeApiKey: + process.env.NEXT_PUBLIC_BICONOMY_MEE_API_KEY || + "", + /** Base URL of the Biconomy v2→Nexus upgrade server (mini bundler). e.g. http://localhost:3000 when running locally. */ + bundlerServerUrl: + process.env.NEXT_PUBLIC_BUNDLER_SERVER_URL || "", maintenanceEnabled: process.env.NEXT_PUBLIC_MAINTENANCE_NOTICE_ENABLED === "true" && !!(process.env.NEXT_PUBLIC_MAINTENANCE_SCHEDULE || "").trim(), diff --git a/app/pages/TransactionPreview.tsx b/app/pages/TransactionPreview.tsx index 712a35e6..4e3eff8f 100644 --- a/app/pages/TransactionPreview.tsx +++ b/app/pages/TransactionPreview.tsx @@ -390,9 +390,9 @@ export const TransactionPreview = ({ }); - const biconomyApiKey = config.biconomyPaymasterKey; + const biconomyApiKey = config.biconomyMeeApiKey; if (!biconomyApiKey) { - throw new Error("Biconomy paymaster API key not configured"); + throw new Error("Biconomy MEE API key not configured. Set NEXT_PUBLIC_BICONOMY_MEE_API_KEY."); } const meeClient = await createMeeClient({ diff --git a/app/pages/TransactionStatus.tsx b/app/pages/TransactionStatus.tsx index 193052a4..fd1c2ea3 100644 --- a/app/pages/TransactionStatus.tsx +++ b/app/pages/TransactionStatus.tsx @@ -564,6 +564,7 @@ export function TransactionStatus({ accountIdentifier: String(formMethods.watch("accountIdentifier") || ""), type: (formMethods.watch("accountType") as "bank" | "mobile_money") || "bank", + currency: String(formMethods.watch("currency") || ""), }; // Save recipient via API diff --git a/app/providers.tsx b/app/providers.tsx index 5af1fee6..226c474c 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -53,22 +53,8 @@ function PrivyConfigWrapper({ appId={privyAppId} config={isDark ? darkModeConfig : lightModeConfig} > - + {/* Sponsorship is handled via Biconomy MEE (Supertransaction API). */} + {children}