From e795ff40041a1cb51a49d84018aaa756a16d25c8 Mon Sep 17 00:00:00 2001 From: terrancrypt Date: Mon, 18 Aug 2025 02:32:48 +0700 Subject: [PATCH 1/7] feat: enhance gas estimation and error handling in transaction confirmation - Introduced a new helper function for parsing chain IDs from strings to numbers. - Implemented a gas estimation skeleton component for improved user experience during gas estimation. - Added error handling with retry logic for gas estimation failures, allowing users to retry fetching gas estimates. - Refactored gas estimation logic to fetch gas price and estimate in parallel, improving efficiency. - Updated UI components to display gas estimation details more clearly, including total costs and gas fee breakdowns. - Enhanced button components to support disabled states for better user interaction. --- src/background/handlers/evm-rpc-handler.ts | 28 +- .../send-token/send-on-evm/confirm-send.tsx | 830 +++++++++++------- src/client/components/ui/button.tsx | 3 + 3 files changed, 551 insertions(+), 310 deletions(-) diff --git a/src/background/handlers/evm-rpc-handler.ts b/src/background/handlers/evm-rpc-handler.ts index 45cabbb..8959971 100644 --- a/src/background/handlers/evm-rpc-handler.ts +++ b/src/background/handlers/evm-rpc-handler.ts @@ -78,6 +78,14 @@ const createSuccessResponse = (data: any): MessageResponse => { }; }; +// Helper function to parse chainId from string to number +const parseChainId = (chainId: string): number => { + // Handle both hex (0x...) and decimal string formats + return chainId.startsWith('0x') + ? parseInt(chainId, 16) + : parseInt(chainId, 10); +}; + // Helper function to create error response const createErrorResponse = (error: string, code?: number): MessageResponse => { return { @@ -104,7 +112,7 @@ export const evmRpcHandler = { // Convert chainId to number if it's a string let targetChainId: number; if (typeof chainId === 'string') { - targetChainId = parseInt(chainId, 10); + targetChainId = parseChainId(chainId); if (isNaN(targetChainId)) { return createErrorResponse(`Invalid chainId: ${chainId}`, 4001); } @@ -144,7 +152,7 @@ export const evmRpcHandler = { // Convert chainId to number if it's a string let targetChainId: number; if (typeof chainId === 'string') { - targetChainId = parseInt(chainId, 10); + targetChainId = parseChainId(chainId); if (isNaN(targetChainId)) { return createErrorResponse(`Invalid chainId: ${chainId}`, 4001); } @@ -177,7 +185,7 @@ export const evmRpcHandler = { // Convert chainId to number if it's a string let targetChainId: number; if (typeof chainId === 'string') { - targetChainId = parseInt(chainId, 10); + targetChainId = parseChainId(chainId); if (isNaN(targetChainId)) { return createErrorResponse(`Invalid chainId: ${chainId}`, 4001); } @@ -219,7 +227,10 @@ export const evmRpcHandler = { // Convert chainId to number if it's a string let targetChainId: number; if (typeof chainId === 'string') { - targetChainId = parseInt(chainId, 10); + // Handle both hex (0x...) and decimal string formats + targetChainId = chainId.startsWith('0x') + ? parseInt(chainId, 16) + : parseChainId(chainId); if (isNaN(targetChainId)) { return createErrorResponse(`Invalid chainId: ${chainId}`, 4001); } @@ -252,7 +263,10 @@ export const evmRpcHandler = { // Convert chainId to number if it's a string let targetChainId: number; if (typeof chainId === 'string') { - targetChainId = parseInt(chainId, 10); + // Handle both hex (0x...) and decimal string formats + targetChainId = chainId.startsWith('0x') + ? parseInt(chainId, 16) + : parseChainId(chainId); if (isNaN(targetChainId)) { return createErrorResponse(`Invalid chainId: ${chainId}`, 4001); } @@ -287,7 +301,7 @@ export const evmRpcHandler = { // Convert chainId to number if it's a string let targetChainId: number; if (typeof chainId === 'string') { - targetChainId = parseInt(chainId, 10); + targetChainId = parseChainId(chainId); if (isNaN(targetChainId)) { return createErrorResponse(`Invalid chainId: ${chainId}`, 4001); } @@ -326,7 +340,7 @@ export const evmRpcHandler = { // Convert chainId to number if it's a string let targetChainId: number; if (typeof chainId === 'string') { - targetChainId = parseInt(chainId, 10); + targetChainId = parseChainId(chainId); if (isNaN(targetChainId)) { return createErrorResponse(`Invalid chainId: ${chainId}`, 4001); } diff --git a/src/client/components/dialogs/send-token/send-on-evm/confirm-send.tsx b/src/client/components/dialogs/send-token/send-on-evm/confirm-send.tsx index ce9fa3d..f43bd81 100644 --- a/src/client/components/dialogs/send-token/send-on-evm/confirm-send.tsx +++ b/src/client/components/dialogs/send-token/send-on-evm/confirm-send.tsx @@ -6,6 +6,7 @@ import { DialogContent, DialogFooter, DialogWrapper, + IconButton, } from '@/client/components/ui'; import { DialogHeader } from '@/client/components/ui'; import useSendTokenStore from '@/client/hooks/use-send-token-store'; @@ -17,11 +18,11 @@ import useDialogStore from '@/client/hooks/use-dialog-store'; import { ArrowLeft, Send, - CheckCircle, AlertTriangle, X, CircleAlert, - Pen, + ChevronDown, + RefreshCw, } from 'lucide-react'; import { getNetworkIcon } from '@/utils/network-icons'; import { getAddressByDomain } from '@/client/services/hyperliquid-name-api'; @@ -31,6 +32,56 @@ import { } from '@/client/hooks/use-native-balance'; import TokenLogo from '@/client/components/token-logo'; import { ensureTokenDecimals } from '@/client/utils/token-decimals'; +import { Menu } from '@/client/components/ui/menu'; + +// Constants for better maintainability +const GAS_ESTIMATION_CONSTANTS = { + REFRESH_INTERVAL: 30000, // 30 seconds + RETRY_DELAY: 2000, // 2 seconds + MAX_RETRIES: 3, + FALLBACK_GAS_PRICE: 20, // gwei +} as const; + +const GAS_PRIORITY_MULTIPLIERS = { + L2: { + slow: 0.95, + standard: 1.0, + fast: 1.05, + rapid: 1.1, + }, + L1: { + slow: 0.8, + standard: 1.0, + fast: 1.2, + rapid: 1.5, + }, + BUFFER: { + L2: 1.1, + L1: 1.2, + }, +} as const; + +const GAS_PRIORITY_TIMES = { + slow: '~30s', + standard: '~15s', + fast: '~10s', + rapid: '~5s', +} as const; + +const SKELETON_CONSTANTS = { + ANIMATION: 'animate-pulse', + COLORS: { + BACKGROUND: 'bg-gray-300 dark:bg-gray-600', + SHIMMER: 'bg-white/10', + }, + SIZES: { + GAS_LIMIT: 'h-4 w-16', + GAS_PRICE: 'h-4 w-20', + GAS_FEE: 'h-4 w-24', + GAS_USD: 'h-4 w-16', + SPEED: 'h-4 w-18', + }, +} as const; type GasEstimate = { gasLimit: string; @@ -40,6 +91,176 @@ type GasEstimate = { totalCostUsd: string; }; +// Gas estimation skeleton component with consistent header +const GasEstimationSkeleton = () => { + return ( +
+ {/* Header skeleton */} +
+
+

Gas Estimate

+
+ + + +
+ + {/* Gas details skeleton using Menu structure */} + ( +
+ ), + }, + { + label: 'Gas Price', + description: '', + arrowLeftIcon: () => ( +
+ ), + }, + { + label: 'Gas Fee', + description: '', + arrowLeftIcon: () => ( +
+ ), + }, + { + label: 'Gas Fee (USD)', + description: '', + arrowLeftIcon: () => ( +
+ ), + }, + { + label: 'Speed', + description: '', + arrowLeftIcon: () => ( +
+
+
+
+ ), + }, + ]} + /> + + {/* Total cost skeleton */} + ( +
+ ), + }, + ]} + /> +
+ ); +}; + +// Compact error component that fits with Menu design +const GasEstimationError = ({ + error, + onRetry, + retryCount = 0, + isRetrying = false, +}: { + error: string; + onRetry: () => void; + retryCount?: number; + isRetrying?: boolean; +}) => { + const canRetry = retryCount < GAS_ESTIMATION_CONSTANTS.MAX_RETRIES; + const shortError = error.length > 50 ? error.substring(0, 50) + '...' : error; + + return ( +
+ {/* Error header with retry action */} +
+
+

Gas Estimate

+
+ + failed +
+
+ {canRetry && ( + + + + )} +
+ + {/* Error details in Menu format */} + ( +
+ ), + }, + { + label: 'Error', + description: shortError, + }, + ...(retryCount > 0 + ? [ + { + label: 'Attempts', + description: `${retryCount}/${GAS_ESTIMATION_CONSTANTS.MAX_RETRIES}`, + }, + ] + : []), + ]} + /> + + {/* Manual retry for max attempts reached */} + {!canRetry && ( +
+
+ +
+

+ Unable to estimate gas fees +

+

+ Please check your connection and try refreshing the page +

+
+
+
+ )} +
+ ); +}; + // Helper function to get chain ID from chain name const getChainId = (chain: string): string => { switch (chain) { @@ -96,55 +317,56 @@ const estimateGas = async ( }; } - // Get gas estimate - const gasEstimateResponse = await sendMessage('EVM_ESTIMATE_GAS', { - transaction: txObject, - chainId: getChainId(token.chain), - }); + // Get gas estimate and gas price in parallel + const [gasEstimateResponse, gasPriceResponse] = await Promise.all([ + sendMessage('EVM_ESTIMATE_GAS', { + txObject: txObject, + chainId: getChainId(token.chain), + }), + sendMessage('EVM_GET_GAS_PRICE', { + chainId: getChainId(token.chain), + }), + ]); + + // Check if the gas estimate response is successful and has data + if (!gasEstimateResponse?.success || !gasEstimateResponse?.data) { + throw new Error('Failed to get gas estimate from backend'); + } - // Access the nested data properly (backend wraps response in data field) - // First try the direct path, then fallback to nested path - const estimateData = - gasEstimateResponse.data || gasEstimateResponse.data?.data; - const gasLimit = parseInt(estimateData.gas, 16); - - // Handle both legacy and EIP-1559 transactions - let effectiveGasPrice: number; - let gasPriceGwei: number; - - if (estimateData.gasPrice) { - // Legacy transaction (type 0x0) - effectiveGasPrice = parseInt(estimateData.gasPrice, 16); - - gasPriceGwei = effectiveGasPrice / 1e9; - } else if (estimateData.maxFeePerGas) { - // EIP-1559 transaction (type 0x2) - effectiveGasPrice = parseInt(estimateData.maxFeePerGas, 16); - gasPriceGwei = effectiveGasPrice / 1e9; - - // Log additional EIP-1559 info if available - // if (estimateData.maxPriorityFeePerGas) { - // const priorityFeeGwei = Math.round( - // parseInt(estimateData.maxPriorityFeePerGas, 16) / 1e9 - // ); - // } - } else { - // Fallback if neither is available - console.warn('⚠️ No gas price data in response, using fallback'); - effectiveGasPrice = 20e9; // 20 Gwei fallback - gasPriceGwei = 20; + const estimateData = gasEstimateResponse.data; + + // Check if gasEstimate exists in the response + if (!estimateData.gasEstimate) { + throw new Error('Gas estimate not found in response'); + } + + const gasLimit = parseInt(estimateData.gasEstimate, 16); + + // Handle gas price - throw error if not available instead of using fallback + if (!gasPriceResponse?.success || !gasPriceResponse?.data?.gasPrice) { + throw new Error( + 'Unable to fetch current gas price. Please try again later.' + ); } + const effectiveGasPrice = parseInt(gasPriceResponse.data.gasPrice, 16); + const gasPriceGwei = effectiveGasPrice / 1e9; + // Calculate gas cost in ETH const gasCostWei = gasLimit * effectiveGasPrice; const gasCostEth = gasCostWei / 1e18; - let nativeTokenPrice = 3000; // Default fallback - const nativeToken = nativeTokens.find( (t: NativeToken) => t.chain === token.chain ); - nativeTokenPrice = nativeToken?.usdPrice || 3000; + + if (!nativeToken?.usdPrice) { + throw new Error( + 'Unable to fetch current token price for gas calculation. Please try again later.' + ); + } + + const nativeTokenPrice = nativeToken.usdPrice; const gasCostUsd = gasCostEth * nativeTokenPrice; const tokenTotalCostUsd = parseFloat(amount) * (token.usdPrice || 0); @@ -162,25 +384,8 @@ const estimateGas = async ( }; } catch (error) { console.error('❌ Gas estimation failed:', error); - - // Fallback to estimated values if RPC fails - const fallbackGasLimit = token.symbol === 'ETH' ? '21000' : '65000'; - const fallbackGasPrice = '20'; // 20 gwei - const fallbackGasCostEth = - (parseInt(fallbackGasLimit) * parseInt(fallbackGasPrice) * 1e9) / 1e18; // Convert gwei to ETH - const fallbackGasCostUsd = fallbackGasCostEth * 3000; - - console.warn('⚠️ Using fallback gas estimation'); - const tokenTotalCostUsd = parseFloat(amount) * (token.usdPrice || 0); - return { - gasLimit: fallbackGasLimit, - gasPrice: fallbackGasPrice, - gasCostEth: fallbackGasCostEth.toFixed(8), - gasCostUsd: fallbackGasCostUsd.toFixed(6), - totalCostUsd: (tokenTotalCostUsd + fallbackGasCostUsd).toFixed(6), - transactionType: 'fallback', - isEIP1559: false, - }; + // Re-throw the error instead of providing fallback values + throw error; } }; @@ -214,28 +419,28 @@ const GasFeeSelection = ({ }>({ slow: { gasPrice: '', - time: '~30s', + time: GAS_PRIORITY_TIMES.slow, gasCostEth: '', gasCostUsd: '', totalCostUsd: '', }, standard: { gasPrice: '', - time: '~15s', + time: GAS_PRIORITY_TIMES.standard, gasCostEth: '', gasCostUsd: '', totalCostUsd: '', }, fast: { gasPrice: '', - time: '~10s', + time: GAS_PRIORITY_TIMES.fast, gasCostEth: '', gasCostUsd: '', totalCostUsd: '', }, rapid: { gasPrice: '', - time: '~5s', + time: GAS_PRIORITY_TIMES.rapid, gasCostEth: '', gasCostUsd: '', totalCostUsd: '', @@ -250,18 +455,8 @@ const GasFeeSelection = ({ token.chain === 'hyperevm'; const multipliers = isL2 - ? { - slow: 0.95, - standard: 1.0, - fast: 1.05, - rapid: 1.1, - } - : { - slow: 0.8, - standard: 1.0, - fast: 1.2, - rapid: 1.5, - }; + ? GAS_PRIORITY_MULTIPLIERS.L2 + : GAS_PRIORITY_MULTIPLIERS.L1; return baseGasPrice * multipliers[priority]; } @@ -392,6 +587,11 @@ const ConfirmSend = () => { const { getActiveAccountWalletObject } = useWalletStore(); const [gasEstimate, setGasEstimate] = useState(null); const [isEstimatingGas, setIsEstimatingGas] = useState(true); + const [gasEstimationError, setGasEstimationError] = useState( + null + ); + const [gasRetryCount, setGasRetryCount] = useState(0); + const [isRetryingGas, setIsRetryingGas] = useState(false); const [addressDomain, setAddressDomain] = useState(null); const { nativeTokens } = useNativeBalance(); const [isHaveEnoughGasFee, setIsHaveEnoughGasFee] = useState(true); @@ -399,6 +599,7 @@ const ConfirmSend = () => { const [gasRefreshCounter, setGasRefreshCounter] = useState(0); const [lastGasUpdate, setLastGasUpdate] = useState(Date.now()); const [refreshCountdown, setRefreshCountdown] = useState(30); + const [isGasCollapsibleOpen, setIsGasCollapsibleOpen] = useState(false); const activeAccountAddress = getActiveAccountWalletObject()?.eip155?.address; const isHLName = recipient.match(/^[a-zA-Z0-9]+\.hl$/); @@ -414,32 +615,66 @@ const ConfirmSend = () => { } }, [recipient]); - useEffect(() => { - const performGasEstimation = async () => { - if (token && amount && recipient && activeAccountAddress) { - setIsEstimatingGas(true); - if (isHLName && !addressDomain) { - setIsEstimatingGas(false); - return; - } + // Gas estimation function with retry logic + const performGasEstimation = async (isRetry = false) => { + if (!token || !amount || !recipient || !activeAccountAddress) return; - try { - const estimate = await estimateGas( - token, - amount, - addressDomain || recipient, - activeAccountAddress, - nativeTokens - ); - setGasEstimate(estimate); - } catch (error) { - console.error('Failed to estimate gas:', error); - } finally { - setIsEstimatingGas(false); + if (isHLName && !addressDomain) { + setIsEstimatingGas(false); + return; + } + + setIsEstimatingGas(true); + if (isRetry) { + setIsRetryingGas(true); + } + + try { + const estimate = await estimateGas( + token, + amount, + addressDomain || recipient, + activeAccountAddress, + nativeTokens + ); + setGasEstimate(estimate); + setGasEstimationError(null); + setGasRetryCount(0); // Reset retry count on success + } catch (error) { + console.error('Failed to estimate gas:', error); + setGasEstimate(null); + const errorMessage = + error instanceof Error ? error.message : 'Unable to estimate gas fees'; + setGasEstimationError(errorMessage); + + // Auto-retry logic for retryable errors + if (!isRetry && gasRetryCount < GAS_ESTIMATION_CONSTANTS.MAX_RETRIES) { + const isRetryableError = + errorMessage.includes('fetch') || + errorMessage.includes('network') || + errorMessage.includes('timeout') || + errorMessage.includes('Unable to fetch'); + + if (isRetryableError) { + setGasRetryCount(prev => prev + 1); + setTimeout(() => { + performGasEstimation(true); + }, GAS_ESTIMATION_CONSTANTS.RETRY_DELAY); } } - }; + } finally { + setIsEstimatingGas(false); + setIsRetryingGas(false); + } + }; + + // Manual retry handler + const handleGasRetry = () => { + setGasRetryCount(prev => prev + 1); + performGasEstimation(true); + }; + useEffect(() => { performGasEstimation(); }, [ token, @@ -469,17 +704,18 @@ const ConfirmSend = () => { useEffect(() => { if (!token || !amount || !recipient || !activeAccountAddress) return; - const GAS_REFRESH_INTERVAL = 30000; // 30 seconds - const refreshInterval = setInterval(() => { const timeSinceLastUpdate = Date.now() - lastGasUpdate; // Only refresh if enough time has passed and we're not currently estimating - if (timeSinceLastUpdate >= GAS_REFRESH_INTERVAL && !isEstimatingGas) { + if ( + timeSinceLastUpdate >= GAS_ESTIMATION_CONSTANTS.REFRESH_INTERVAL && + !isEstimatingGas + ) { setGasRefreshCounter(prev => prev + 1); setLastGasUpdate(Date.now()); } - }, GAS_REFRESH_INTERVAL); + }, GAS_ESTIMATION_CONSTANTS.REFRESH_INTERVAL); return () => clearInterval(refreshInterval); }, [ @@ -530,8 +766,10 @@ const ConfirmSend = () => { // Fallback if gas price is 0 or invalid if (!gasPriceGwei || gasPriceGwei <= 0) { - console.warn('⚠️ Invalid gas price, using fallback of 20 gwei'); - gasPriceGwei = 20; + console.warn( + `⚠️ Invalid gas price, using fallback of ${GAS_ESTIMATION_CONSTANTS.FALLBACK_GAS_PRICE} gwei` + ); + gasPriceGwei = GAS_ESTIMATION_CONSTANTS.FALLBACK_GAS_PRICE; } // Add safety buffer to gas price (10% extra) to account for network changes @@ -539,7 +777,9 @@ const ConfirmSend = () => { token.chain === 'arbitrum' || token.chain === 'base' || token.chain === 'hyperevm'; - const bufferMultiplier = isL2 ? 1.1 : 1.2; // Smaller buffer for L2s + const bufferMultiplier = isL2 + ? GAS_PRIORITY_MULTIPLIERS.BUFFER.L2 + : GAS_PRIORITY_MULTIPLIERS.BUFFER.L1; gasPriceGwei = Math.ceil(gasPriceGwei * bufferMultiplier * 1000) / 1000; // Round to 3 decimals const gasPriceWei = Math.floor(gasPriceGwei * 1e9); @@ -688,7 +928,7 @@ const ConfirmSend = () => { return ( } rightContent={ @@ -702,155 +942,161 @@ const ConfirmSend = () => { /> {token && ( -
- {/* Transaction Summary */} -
-

- - Transaction Summary -

- -
-
- Token -
-
- -
- - {token.symbol} - -
+
+ {/* Token Display */} +
+ + {token.chain && ( +
+ {token.chain}
- -
- Amount -
-
- {amount} {token.symbol} -
-
- ≈ $ - {( - (parseFloat(amount) || 0) * (token.usdPrice || 0) - ).toFixed(2)} -
-
-
- -
- To - - {addressDomain - ? addressDomain.slice(0, 6) + - '...' + - addressDomain.slice(-4) - : recipient.slice(0, 6) + - '...' + - recipient.slice(-4)}{' '} - {isHLName && `(${recipient})`} - -
- -
- Network -
- {token.chain} - {token.chain} -
-
-
+ )}
+ {/* Transaction Summary */} + + {/* Gas Fee Details */} {isEstimatingGas ? ( -
-
-
- Estimating gas fees... -
-
+ + ) : gasEstimationError ? ( + ) : gasEstimate ? ( -
-

-
- - Gas Fee Estimation -
-
- {refreshCountdown > 0 - ? `Refresh in ${refreshCountdown}s` - : 'Auto-refreshing...'} -
-
-

- -
-
- Gas Limit - {gasEstimate.gasLimit} -
- -
- Gas Price - - {gasEstimate.gasPrice} gwei - -
- -
- Gas Fee -
-
- {gasEstimate.gasCostEth}{' '} - {token.chain === 'hyperevm' ? 'HYPE' : 'ETH'} -
-
- ≈ ${gasEstimate.gasCostUsd} -
+ <> +
+ {/* Gas estimate header with refresh */} +
+
+

Gas Estimate

+ {refreshCountdown > 0 ? ( +
+
20 + ? 'bg-green-400' + : refreshCountdown > 10 + ? 'bg-yellow-400' + : 'bg-orange-400' + }`} + >
+ + {refreshCountdown}s + +
+ ) : ( +
+
+ + updating... + +
+ )}
+ setGasRefreshCounter(prev => prev + 1)} + disabled={isEstimatingGas} + > + +
- - - - - Speed - - + ( + + ), + onClick: () => { + // Toggle collapsible and update state + setIsGasCollapsibleOpen(!isGasCollapsibleOpen); + const trigger = document.querySelector( + '[data-state]' + ) as HTMLElement; + if (trigger) trigger.click(); + }, + }, + ]} + /> + + + { /> - -
-
- - Total Cost - -
-
- ${gasEstimate.totalCostUsd} -
-
- Token + Gas Fee -
-
-
-
-
- ) : ( -
-

- ⚠️ Failed to estimate gas fees. Please try again. -

-
- )} - {!isHaveEnoughGasFee ? ( -
-

- - Don't have enough gas fee. Available:{' '} - {parseFloat( - nativeTokens.find( - (t: NativeToken) => t.chain === token.chain - )?.balance || '0' - ).toFixed(2)}{' '} - {token.chain === 'hyperevm' ? ' HYPE' : ' ETH'} -

-
+ + ) : ( -
-

- ⚠️ Please double-check all transaction details. This action - cannot be undone. -

-
+ )}
)} - diff --git a/src/client/components/ui/button.tsx b/src/client/components/ui/button.tsx index b3c1fd3..5050068 100644 --- a/src/client/components/ui/button.tsx +++ b/src/client/components/ui/button.tsx @@ -38,14 +38,17 @@ export const IconButton = ({ children, onClick, className, + disabled, }: { children: React.ReactNode; onClick?: () => void; className?: string; + disabled?: boolean; }) => { return ( {/* Address Dropdown */} @@ -416,30 +417,26 @@ const SendToken = () => { {/* Amount Input */}
- - + + + Balance:{' '} + {inputMode === 'token' + ? `${formatDisplayNumber( + token.balanceFormatted, + 'token' + )} ${token.symbol}` + : formatCurrency(token.usdValue)} +
{/* Input field with Max button */} -
+
setAmount(e.target.value)} placeholder={inputMode === 'token' ? '0.0' : '0.00'} - className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 bg-[var(--card-color)] text-white placeholder-gray-400 pr-12 text-base transition-colors duration-200 ${ + className={`flex-1 px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 bg-[var(--card-color)] text-white placeholder-gray-400 text-base transition-colors duration-200 ${ amount && !isValidAmount ? 'border-red-500 focus:ring-red-500' : 'border-white/10 focus:ring-[var(--primary-color-light)]' @@ -449,7 +446,7 @@ const SendToken = () => { /> @@ -457,19 +454,14 @@ const SendToken = () => { {/* Conversion and Available Balance */}
- + {inputMode === 'token' ? `≈ $${conversionAmount}` - : `≈ ${conversionAmount} ${token.symbol}`} - - - Available:{' '} - {inputMode === 'token' - ? `${formatDisplayNumber( - token.balanceFormatted, - 'token' - )} ${token.symbol}` - : formatCurrency(token.usdValue)} + : `≈ ${conversionAmount} ${token.symbol}`}{' '} +
diff --git a/src/client/components/dialogs/send-token/send-on-evm/transaction-success.tsx b/src/client/components/dialogs/send-token/send-on-evm/transaction-success.tsx index 7e40c7d..254eb27 100644 --- a/src/client/components/dialogs/send-token/send-on-evm/transaction-success.tsx +++ b/src/client/components/dialogs/send-token/send-on-evm/transaction-success.tsx @@ -5,59 +5,52 @@ import { DialogWrapper, } from '@/client/components/ui'; import { DialogHeader } from '@/client/components/ui'; +import { Menu } from '@/client/components/ui/menu'; import useSendTokenStore from '@/client/hooks/use-send-token-store'; import useDialogStore from '@/client/hooks/use-dialog-store'; -import { CheckCircle, Send, X, ExternalLink } from 'lucide-react'; +import { CheckCircle2, Send, X, ExternalLink, Copy, Check } from 'lucide-react'; import { getNetworkIcon } from '@/utils/network-icons'; import TokenLogo from '@/client/components/token-logo'; import { motion } from 'motion/react'; import { useEffect, useState } from 'react'; import { getAddressByDomain } from '@/client/services/hyperliquid-name-api'; +import { formatTokenAmount, truncateAddress } from '@/client/utils/formatters'; + +// Constants for better maintainability +const EXPLORER_URLS = { + ethereum: 'https://etherscan.io/tx/', + arbitrum: 'https://arbiscan.io/tx/', + base: 'https://basescan.org/tx/', + polygon: 'https://polygonscan.com/tx/', + optimism: 'https://optimistic.etherscan.io/tx/', + bsc: 'https://bscscan.com/tx/', + hyperevm: 'https://explorer.hyperliquid.xyz/tx/', +} as const; + +const NETWORK_NAMES = { + ethereum: 'Ethereum', + arbitrum: 'Arbitrum One', + base: 'Base', + polygon: 'Polygon', + optimism: 'Optimism', + bsc: 'BNB Smart Chain', + hyperevm: 'HyperEVM', +} as const; // Get explorer URL based on chain const getExplorerUrl = (chain: string, txHash: string): string => { if (txHash === 'Processing...' || !txHash) return ''; - switch (chain) { - case 'ethereum': - return `https://etherscan.io/tx/${txHash}`; - case 'arbitrum': - return `https://arbiscan.io/tx/${txHash}`; - case 'base': - return `https://basescan.org/tx/${txHash}`; - case 'polygon': - return `https://polygonscan.com/tx/${txHash}`; - case 'optimism': - return `https://optimistic.etherscan.io/tx/${txHash}`; - case 'bsc': - return `https://bscscan.com/tx/${txHash}`; - case 'hyperevm': - return `https://explorer.hyperliquid.xyz/tx/${txHash}`; - default: - return ''; - } + const baseUrl = EXPLORER_URLS[chain as keyof typeof EXPLORER_URLS]; + return baseUrl ? `${baseUrl}${txHash}` : ''; }; // Get network display name const getNetworkDisplayName = (chain: string): string => { - switch (chain) { - case 'ethereum': - return 'Ethereum'; - case 'arbitrum': - return 'Arbitrum One'; - case 'base': - return 'Base'; - case 'polygon': - return 'Polygon'; - case 'optimism': - return 'Optimism'; - case 'bsc': - return 'BNB Smart Chain'; - case 'hyperevm': - return 'HyperEVM'; - default: - return chain.charAt(0).toUpperCase() + chain.slice(1); - } + return ( + NETWORK_NAMES[chain as keyof typeof NETWORK_NAMES] || + chain.charAt(0).toUpperCase() + chain.slice(1) + ); }; const TransactionSuccess = () => { @@ -74,6 +67,7 @@ const TransactionSuccess = () => { } = useSendTokenStore(); const { closeDialog } = useDialogStore(); const [recipientAddress, setRecipientAddress] = useState(recipient); + const [copied, setCopied] = useState(false); useEffect(() => { const resolveRecipientAddress = async () => { @@ -127,28 +121,89 @@ const TransactionSuccess = () => { } }; + const handleCopyHash = () => { + if (!transactionHash || transactionHash === 'Processing...') return; + + navigator.clipboard + .writeText(transactionHash) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }) + .catch(error => { + console.error('Failed to copy transaction hash:', error); + }); + }; + + const handleCopyAddress = () => { + navigator.clipboard.writeText(recipientAddress).catch(error => { + console.error('Failed to copy address:', error); + }); + }; + if (!token) { return null; } const explorerUrl = getExplorerUrl(token.chain, transactionHash); + const formattedAmount = formatTokenAmount(amount); + + // Prepare menu items + const menuItems: Array<{ + label: string; + description?: string; + onClick?: () => void; + arrowLeftIcon?: React.ElementType; + itemClassName?: string; + isCentered?: boolean; + }> = [ + { + label: 'Amount', + description: `${formattedAmount} ${token.symbol}`, + }, + { + label: 'To Address', + description: + truncateAddress(recipientAddress) + + (recipient.endsWith('.hl') ? ` (${recipient})` : ''), + onClick: handleCopyAddress, + arrowLeftIcon: Copy, + }, + { + label: 'Network', + description: getNetworkDisplayName(token.chain), + }, + ]; + + // Add transaction hash item if available + if (transactionHash && transactionHash !== 'Processing...') { + menuItems.push({ + label: 'Transaction Hash', + description: truncateAddress(transactionHash), + onClick: handleCopyHash, + arrowLeftIcon: copied ? Check : Copy, + }); + } + + // Add explorer link if available + if (explorerUrl) { + menuItems.push({ + label: 'View on Explorer', + itemClassName: 'text-[var(--primary-color-light)] justify-center', + onClick: handleViewOnExplorer, + isCentered: true, + }); + } return ( - - - } + icon={} /> -
+
{/* Success Animation */} { stiffness: 200, damping: 15, }} - className="flex items-center justify-center" + className="mb-4" >
-
- +
+
- {/* Success Message */} -
-

Transaction Sent!

-

- Your transaction has been successfully submitted to the{' '} - {getNetworkDisplayName(token.chain)} network. -

-
- - {/* Transaction Details */} -
-

- Transaction Details -

- -
- {/* Amount and Token */} -
- Amount -
-
- - - {amount} {token.symbol} - -
- {token.usdPrice && ( -
- ≈ $ - {( - (parseFloat(amount) || 0) * (token.usdPrice || 0) - ).toFixed(2)} -
- )} -
-
- - {/* Recipient */} -
- To - - {recipientAddress.slice(0, 6)}... - {recipientAddress.slice(-4)}{' '} - {recipient.endsWith('.hl') && ( - ({recipient}) - )} - -
- - {/* Network */} -
- Network -
- {getNetworkDisplayName(token.chain)} - - {getNetworkDisplayName(token.chain)} - -
-
- - {/* Transaction Hash */} - {transactionHash && transactionHash !== 'Processing...' && ( -
- Transaction Hash -
- - {transactionHash.slice(0, 6)}... - {transactionHash.slice(-4)} - - {explorerUrl && ( - - )} -
-
- )} -
-
- - {/* Explorer Link Button */} - {explorerUrl && ( - - )} - - {/* Info Message */} -
-

- 💡 Your transaction is being processed. It may take a few moments - to appear in your wallet balance and transaction history. -

-
+ {/* Transaction Details Menu */} +
- - From dc713d8d50ac29ed3ce9cfbb69e3f34e73308a5a Mon Sep 17 00:00:00 2001 From: terrancrypt Date: Mon, 18 Aug 2025 02:54:39 +0700 Subject: [PATCH 3/7] chore: update version number to 0.5.13 in manifest.json --- src/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manifest.json b/src/manifest.json index 1f9c276..0be102c 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "Purro (Beta)", "description": "The Purr-fect Web3 Wallet", - "version": "0.5.12", + "version": "0.5.13", "manifest_version": 3, "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-src 'none';" From 92f4ea7175930efbbfbf5def28b9dc26feef67ca Mon Sep 17 00:00:00 2001 From: terrancrypt Date: Mon, 18 Aug 2025 03:19:49 +0700 Subject: [PATCH 4/7] feat: add private key validation functionality - Implemented a new method in accountHandler to validate private keys without importing them, supporting multiple chain types (EVM, Solana, Sui). - Updated message-handler to handle 'VALIDATE_PRIVATE_KEY' requests. - Refactored the import-private-key component to utilize the new validation method, improving user experience and error handling during private key import. --- src/background/handlers/account-handler.ts | 44 +++++ src/background/message-handler.ts | 3 + .../import-steps/import-private-key.tsx | 116 +++---------- src/client/utils/private-key-validation.ts | 152 ++++++++++++++++++ 4 files changed, 223 insertions(+), 92 deletions(-) create mode 100644 src/client/utils/private-key-validation.ts diff --git a/src/background/handlers/account-handler.ts b/src/background/handlers/account-handler.ts index 75ee8d5..dad6a91 100644 --- a/src/background/handlers/account-handler.ts +++ b/src/background/handlers/account-handler.ts @@ -36,6 +36,50 @@ export const accountHandler = { } }, + // Validate private key without importing + async validatePrivateKey(data: { + privateKey: string; + chain: ChainType; + }): Promise<{ isValid: boolean; address: string }> { + try { + if (!data.privateKey || typeof data.privateKey !== 'string') { + return { isValid: false, address: '' }; + } + + let walletKeys: any; + let isValid = false; + + // Validate private key based on chain type + if (data.chain === 'eip155') { + isValid = evmWalletKeyUtils.isValidPrivateKey(data.privateKey); + if (isValid) { + walletKeys = evmWalletKeyUtils.fromPrivateKey(data.privateKey); + } + } else if (data.chain === 'solana') { + isValid = solanaWalletKeyUtils.isValidPrivateKey(data.privateKey); + if (isValid) { + walletKeys = solanaWalletKeyUtils.fromPrivateKey(data.privateKey); + } + } else if (data.chain === 'sui') { + isValid = suiWalletKeyUtils.isValidPrivateKey(data.privateKey); + if (isValid) { + walletKeys = suiWalletKeyUtils.fromPrivateKey(data.privateKey); + } + } else { + return { isValid: false, address: '' }; + } + + if (isValid && walletKeys) { + return { isValid: true, address: walletKeys.address }; + } else { + return { isValid: false, address: '' }; + } + } catch (error) { + console.error('Private key validation error:', error); + return { isValid: false, address: '' }; + } + }, + async isPrivateKeyAlreadyImported(privateKey: string): Promise { try { // Validate input diff --git a/src/background/message-handler.ts b/src/background/message-handler.ts index 7007917..d049058 100644 --- a/src/background/message-handler.ts +++ b/src/background/message-handler.ts @@ -125,6 +125,9 @@ export class MessageHandler { data.accountName ); break; + case 'VALIDATE_PRIVATE_KEY': + result = await accountHandler.validatePrivateKey(data); + break; case 'IS_WATCH_ONLY_ADDRESS_EXISTS': result = await accountHandler.isWalletAddressExists(data.address); break; diff --git a/src/client/screens/onboarding/import-steps/import-private-key.tsx b/src/client/screens/onboarding/import-steps/import-private-key.tsx index 3c291ba..ad5f698 100644 --- a/src/client/screens/onboarding/import-steps/import-private-key.tsx +++ b/src/client/screens/onboarding/import-steps/import-private-key.tsx @@ -4,77 +4,13 @@ import { Check, X } from 'lucide-react'; import { useState, useEffect } from 'react'; import useWallet from '@/client/hooks/use-wallet'; import useWalletStore from '@/client/hooks/use-wallet-store'; -import { - evmWalletKeyUtils, - solanaWalletKeyUtils, - suiWalletKeyUtils, -} from '@/background/utils/keys'; +import { validatePrivateKeyFormat } from '@/client/utils/private-key-validation'; // Constants for easy customization -const VALIDATION_CONFIG = { - EVM_CHAINS: ['ethereum', 'hyperevm', 'base', 'arbitrum'], - ERROR_MESSAGES: { - INVALID_PRIVATE_KEY: 'Invalid private key. Please try again.', - ALREADY_IMPORTED: 'This private key is already imported.', - }, +const COMPONENT_CONFIG = { ACCOUNT_NAME_PREFIX: 'Account', } as const; -// Utility function to validate private key and get address -const validatePrivateKeyFormat = ( - privateKeyValue: string, - chain: string | null -) => { - if (!privateKeyValue.trim() || !chain) { - return { isValid: false, address: '' }; - } - - try { - let isValid = false; - let walletAddress = ''; - - if ( - VALIDATION_CONFIG.EVM_CHAINS.includes( - chain as (typeof VALIDATION_CONFIG.EVM_CHAINS)[number] - ) - ) { - try { - isValid = evmWalletKeyUtils.isValidPrivateKey(privateKeyValue); - if (isValid) { - const wallet = evmWalletKeyUtils.fromPrivateKey(privateKeyValue); - walletAddress = wallet.address; - } - } catch { - isValid = false; - } - } else if (chain === 'solana') { - try { - isValid = solanaWalletKeyUtils.isValidPrivateKey(privateKeyValue); - if (isValid) { - const wallet = solanaWalletKeyUtils.fromPrivateKey(privateKeyValue); - walletAddress = wallet.address; - } - } catch { - isValid = false; - } - } else if (chain === 'sui') { - try { - isValid = suiWalletKeyUtils.isValidPrivateKey(privateKeyValue); - if (isValid) { - const wallet = suiWalletKeyUtils.fromPrivateKey(privateKeyValue); - walletAddress = wallet.address; - } - } catch { - isValid = false; - } - } - - return { isValid, address: walletAddress }; - } catch { - return { isValid: false, address: '' }; - } -}; - const ImportPrivateKey = ({ onNext }: { onNext: () => void }) => { const { chain, privateKey, setPrivateKey, accountName, setAccountName } = useCreateWalletStore(); @@ -87,7 +23,7 @@ const ImportPrivateKey = ({ onNext }: { onNext: () => void }) => { useEffect(() => { if (!accountName && initialized) { setAccountName( - `${VALIDATION_CONFIG.ACCOUNT_NAME_PREFIX} ${accounts.length > 0 ? accounts.length + 1 : 1}` + `${COMPONENT_CONFIG.ACCOUNT_NAME_PREFIX} ${accounts.length > 0 ? accounts.length + 1 : 1}` ); } }, [accounts, accountName, setAccountName, initialized]); @@ -102,7 +38,7 @@ const ImportPrivateKey = ({ onNext }: { onNext: () => void }) => { const validation = validatePrivateKeyFormat(privateKey, chain ?? null); if (!validation.isValid) { - throw new Error(VALIDATION_CONFIG.ERROR_MESSAGES.INVALID_PRIVATE_KEY); + throw new Error('Invalid private key. Please try again.'); } // Set the address for display @@ -113,7 +49,7 @@ const ImportPrivateKey = ({ onNext }: { onNext: () => void }) => { const exists = await checkPrivateKeyExists(privateKey); if (exists) { - setError(VALIDATION_CONFIG.ERROR_MESSAGES.ALREADY_IMPORTED); + setError('This private key is already imported.'); return false; } } catch { @@ -122,7 +58,7 @@ const ImportPrivateKey = ({ onNext }: { onNext: () => void }) => { return true; } catch { - setError(VALIDATION_CONFIG.ERROR_MESSAGES.INVALID_PRIVATE_KEY); + setError('Invalid private key. Please try again.'); setAddress(null); return false; } @@ -132,15 +68,9 @@ const ImportPrivateKey = ({ onNext }: { onNext: () => void }) => {
-

Import Private Key

+

Import private key

- {chain == null && 'Select the chain'} - {chain === 'ethereum' && 'Enter your Ethereum private key'} - {chain === 'solana' && 'Enter your Solana private key'} - {chain === 'sui' && 'Enter your Sui private key'} - {chain === 'hyperevm' && 'Enter your Hyperliquid private key'} - {chain === 'base' && 'Enter your Base private key'} - {chain === 'arbitrum' && 'Enter your Arbitrum private key'} + Enter your private key to import the wallet

{chain != null && ( @@ -181,7 +111,7 @@ const ImportPrivateKey = ({ onNext }: { onNext: () => void }) => { 0 ? accounts.length + 1 : 1}`} + placeholder={`${COMPONENT_CONFIG.ACCOUNT_NAME_PREFIX} ${accounts.length > 0 ? accounts.length + 1 : 1}`} value={accountName ?? ''} onChange={e => setAccountName(e.target.value)} className="w-full px-4 py-3 border border-white/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--primary-color-light)] bg-[var(--card-color)] text-white placeholder-gray-400 text-base" @@ -194,25 +124,27 @@ const ImportPrivateKey = ({ onNext }: { onNext: () => void }) => {
)} {address && ( -
+
- {address} + {address}
)}
)}
- +
+ +
); }; diff --git a/src/client/utils/private-key-validation.ts b/src/client/utils/private-key-validation.ts new file mode 100644 index 0000000..7656979 --- /dev/null +++ b/src/client/utils/private-key-validation.ts @@ -0,0 +1,152 @@ +import { ethers } from 'ethers'; + +// Constants for easy customization +const VALIDATION_CONFIG = { + EVM_CHAINS: ['ethereum', 'hyperevm', 'base', 'arbitrum'], + ERROR_MESSAGES: { + INVALID_PRIVATE_KEY: 'Invalid private key. Please try again.', + ALREADY_IMPORTED: 'This private key is already imported.', + }, +} as const; + +export interface PrivateKeyValidationResult { + isValid: boolean; + address: string; +} + +/** + * Validates a private key for the specified chain and returns the corresponding address + * @param privateKeyValue - The private key to validate + * @param chain - The blockchain network (ethereum, hyperevm, base, arbitrum, solana, sui) + * @returns Object with validation result and address + */ +export const validatePrivateKeyFormat = ( + privateKeyValue: string, + chain: string | null +): PrivateKeyValidationResult => { + if (!privateKeyValue.trim() || !chain) { + return { isValid: false, address: '' }; + } + + try { + if ( + VALIDATION_CONFIG.EVM_CHAINS.includes( + chain as (typeof VALIDATION_CONFIG.EVM_CHAINS)[number] + ) + ) { + return validateEvmPrivateKey(privateKeyValue); + } else if (chain === 'solana') { + return validateSolanaPrivateKey(privateKeyValue); + } else if (chain === 'sui') { + return validateSuiPrivateKey(privateKeyValue); + } + + return { isValid: false, address: '' }; + } catch (error) { + console.error('Private key validation error:', error); + return { isValid: false, address: '' }; + } +}; + +/** + * Validates an EVM private key using ethers library + */ +const validateEvmPrivateKey = (privateKeyValue: string): PrivateKeyValidationResult => { + try { + // Check basic format first + let cleanPrivateKey = privateKeyValue; + if (privateKeyValue.startsWith('0x')) { + cleanPrivateKey = privateKeyValue.slice(2); + } + + // Must be valid hex and exactly 64 characters (32 bytes) + if (!/^[0-9a-fA-F]{64}$/.test(cleanPrivateKey)) { + return { isValid: false, address: '' }; + } + + // Try to create wallet and verify it works + const wallet = new ethers.Wallet(privateKeyValue); + + // Verify wallet creation was successful + const address = wallet.address; + const publicKey = wallet.signingKey.publicKey; + + if (address && address.length > 0 && publicKey && publicKey.length > 0) { + return { isValid: true, address }; + } else { + return { isValid: false, address: '' }; + } + } catch { + return { isValid: false, address: '' }; + } +}; + +/** + * Validates a Solana private key (basic format validation) + */ +const validateSolanaPrivateKey = (privateKeyValue: string): PrivateKeyValidationResult => { + try { + // Handle different formats for Solana private keys + if (privateKeyValue.startsWith('[') && privateKeyValue.endsWith(']')) { + // Array format: [1,2,3,...] + const parsed = JSON.parse(privateKeyValue); + if (Array.isArray(parsed) && parsed.length === 64) { + return { + isValid: true, + address: 'Solana address validation requires full backend utilities' + }; + } + } else if (privateKeyValue.startsWith('0x')) { + // Hex format with 0x prefix + const hex = privateKeyValue.slice(2); + if (/^[0-9a-fA-F]{128}$/.test(hex)) { + return { + isValid: true, + address: 'Solana address validation requires full backend utilities' + }; + } + } else if (/^[0-9a-fA-F]{128}$/.test(privateKeyValue)) { + // Plain hex format (64 bytes = 128 hex chars) + return { + isValid: true, + address: 'Solana address validation requires full backend utilities' + }; + } else if (/^[1-9A-HJ-NP-Za-km-z]{88}$/.test(privateKeyValue)) { + // Base58 format (common for Solana) + return { + isValid: true, + address: 'Solana address validation requires full backend utilities' + }; + } + + return { isValid: false, address: '' }; + } catch { + return { isValid: false, address: '' }; + } +}; + +/** + * Validates a Sui private key (basic format validation) + */ +const validateSuiPrivateKey = (privateKeyValue: string): PrivateKeyValidationResult => { + try { + let cleanPrivateKey = privateKeyValue; + if (privateKeyValue.startsWith('0x')) { + cleanPrivateKey = privateKeyValue.slice(2); + } + + // Sui uses Ed25519 keys (32 bytes = 64 hex chars) + if (/^[0-9a-fA-F]{64}$/.test(cleanPrivateKey)) { + return { + isValid: true, + address: 'Sui address validation requires full backend utilities' + }; + } + + return { isValid: false, address: '' }; + } catch { + return { isValid: false, address: '' }; + } +}; + +export { VALIDATION_CONFIG }; \ No newline at end of file From 79500af5ff6c302767419ed3e926f377b5a8e9e9 Mon Sep 17 00:00:00 2001 From: terrancrypt Date: Mon, 18 Aug 2025 03:38:28 +0700 Subject: [PATCH 5/7] feat: enhance onboarding flow with debounce and window management - Introduced a debounce mechanism for opening the onboarding window to prevent multiple instances. - Added checks to ensure the onboarding window does not open if already on the onboarding or import pages. - Implemented a global flag to track the state of the onboarding window and reset it upon closure. - Enhanced cleanup logic to clear timeouts on component unmount, improving resource management. --- src/client/hooks/use-init.ts | 65 ++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/src/client/hooks/use-init.ts b/src/client/hooks/use-init.ts index b7deb21..09a0002 100644 --- a/src/client/hooks/use-init.ts +++ b/src/client/hooks/use-init.ts @@ -1,9 +1,20 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import useWalletStore from './use-wallet-store'; import { STORAGE_KEYS } from '@/background/constants/storage-keys'; +// Constants for easy customization +const ONBOARDING_CONFIG = { + DEBOUNCE_DELAY: 500, + WINDOW_CHECK_INTERVAL: 1000, + FALLBACK_RESET_DELAY: 5000, +} as const; + +// Global flag to prevent multiple onboarding windows +let isOnboardingWindowOpen = false; + const useInit = () => { const { loading, hasWallet, initialized, loadWalletState } = useWalletStore(); + const onboardingTimeoutRef = useRef(null); useEffect(() => { // Initialize wallet state on first load @@ -13,26 +24,66 @@ const useInit = () => { }, [initialized, loading, loadWalletState]); useEffect(() => { + // Clear any existing timeout + if (onboardingTimeoutRef.current) { + clearTimeout(onboardingTimeoutRef.current); + } + // Handle onboarding flow after state is loaded - if (initialized && !loading && !hasWallet) { - window.open('onboarding.html', '_blank'); + const isOnboardingPage = window.location.pathname.includes('onboarding') || + window.location.href.includes('onboarding.html'); + const isImportPage = window.location.pathname.includes('import') || + window.location.href.includes('import.html'); + + if (initialized && !loading && !hasWallet && !isOnboardingPage && !isImportPage && !isOnboardingWindowOpen) { + // Debounce the onboarding window opening + onboardingTimeoutRef.current = setTimeout(() => { + if (!isOnboardingWindowOpen) { + isOnboardingWindowOpen = true; + + const newWindow = window.open('onboarding.html', '_blank'); + + // Reset flag when window is closed + if (newWindow) { + const checkClosed = setInterval(() => { + if (newWindow.closed) { + isOnboardingWindowOpen = false; + clearInterval(checkClosed); + } + }, ONBOARDING_CONFIG.WINDOW_CHECK_INTERVAL); + + // Fallback reset + setTimeout(() => { + isOnboardingWindowOpen = false; + clearInterval(checkClosed); + }, ONBOARDING_CONFIG.FALLBACK_RESET_DELAY); + } else { + isOnboardingWindowOpen = false; + } + } + }, ONBOARDING_CONFIG.DEBOUNCE_DELAY); } + + // Cleanup timeout on unmount + return () => { + if (onboardingTimeoutRef.current) { + clearTimeout(onboardingTimeoutRef.current); + } + }; }, [initialized, loading, hasWallet]); - // Listen for lock state changes from background script + // Listen for storage changes useEffect(() => { function handleStorageChange( changes: { [key: string]: chrome.storage.StorageChange }, areaName: string ) { if (areaName !== 'local') return; - // Refresh when any Purro storage key that may affect wallet state changes + const shouldRefresh = Object.keys(changes).some( key => - // Generic prefixes for account / wallet related keys key.startsWith('purro:account') || key.startsWith('purro:wallet') || - // Explicit keys we rely on key === STORAGE_KEYS.ACCOUNTS || key === STORAGE_KEYS.ACCOUNT_ACTIVE_ACCOUNT || key === STORAGE_KEYS.SESSION_IS_LOCKED From 3a8c07959f8b7a8cad095e26aec643a767429070 Mon Sep 17 00:00:00 2001 From: terrancrypt Date: Mon, 18 Aug 2025 03:40:48 +0700 Subject: [PATCH 6/7] refactor: remove manual onboarding window opening after wallet reset - Updated the resetWallet function to eliminate the manual opening of the onboarding window, as it will now be automatically handled by the use-init hook when the wallet state is detected as false. --- src/client/components/dialogs/settings/reset-wallet.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client/components/dialogs/settings/reset-wallet.tsx b/src/client/components/dialogs/settings/reset-wallet.tsx index 61ceb83..5e07655 100644 --- a/src/client/components/dialogs/settings/reset-wallet.tsx +++ b/src/client/components/dialogs/settings/reset-wallet.tsx @@ -12,7 +12,8 @@ const ResetWallet = ({ onBack }: { onBack: () => void }) => { const handleReset = async () => { await resetWallet(); - window.open('/html/onboarding.html', '_blank'); + // Note: onboarding window will be automatically opened by use-init hook + // when it detects hasWallet = false, so no need to manually open it here }; return ( From 59358ae5722ccf7b68b2b5ce99b4afe02b819103 Mon Sep 17 00:00:00 2001 From: terrancrypt Date: Mon, 18 Aug 2025 03:41:55 +0700 Subject: [PATCH 7/7] chore: update version number to 0.5.14 in manifest.json --- src/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manifest.json b/src/manifest.json index 0be102c..9b23e2a 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "name": "Purro (Beta)", "description": "The Purr-fect Web3 Wallet", - "version": "0.5.13", + "version": "0.5.14", "manifest_version": 3, "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-src 'none';"