diff --git a/src/client/components/dialogs/token-info-dialog.tsx b/src/client/components/dialogs/token-info-dialog.tsx index cb4cf70..3031987 100644 --- a/src/client/components/dialogs/token-info-dialog.tsx +++ b/src/client/components/dialogs/token-info-dialog.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { DialogContent, DialogHeader, @@ -16,6 +16,9 @@ import { BarChart3, Coins, Activity, + AlertTriangle, + ChevronDown, + ChevronUp, } from 'lucide-react'; import { UnifiedToken } from '@/client/components/token-list'; import { formatCurrency } from '@/client/utils/formatters'; @@ -29,11 +32,124 @@ import { import useMainScreenStore from '@/client/hooks/use-main-screen-store'; import useSwapStore from '@/client/hooks/use-swap-store'; +interface ScamAlert { + isScam: boolean; + riskLevel: 'high' | 'medium' | 'low'; + reasons: string[]; +} + interface TokenInfoDialogProps { token: UnifiedToken; onClose: () => void; } +const checkAlertScanToken = (token: UnifiedToken, hasTokenLogo: boolean = false): ScamAlert => { + const reasons: string[] = []; + let riskLevel: 'high' | 'medium' | 'low' = 'low'; + + const name = token.name?.toLowerCase() || ''; + const symbol = token.symbol?.toLowerCase() || ''; + + // High-risk indicators + const highRiskPatterns = [ + // Airdrop/claim scams + /airdrop/i, + /claim/i, + /distribution/i, + /round\s*\d+/i, + /visit.*claim/i, + /claim.*until/i, + + // Telegram/social media indicators + /t\.me/i, + /telegram/i, + /\*visit/i, + /\*claim/i, + + // Suspicious symbols and characters + /[โœ…โšก๐ŸŽ๐Ÿ’ฐ๐Ÿš€]/u, + /[\u0400-\u04FF]/, // Cyrillic characters (like Uะ…Dะก) + /[\u2000-\u206F]/, // General punctuation (invisible chars) + + // Impersonation attempts + /official/i, + /verified/i, + /authentic/i, + ]; + + // Medium-risk indicators + const mediumRiskPatterns = [ + // Suspicious naming patterns + /token.*distribution/i, + /free.*token/i, + /bonus.*token/i, + ]; + + // Check high-risk patterns + for (const pattern of highRiskPatterns) { + if (pattern.test(name) || pattern.test(symbol)) { + reasons.push(`Suspicious pattern detected: ${pattern.source}`); + riskLevel = 'high'; + } + } + + // Check medium-risk patterns (only if not already high risk) + if (riskLevel !== 'high') { + for (const pattern of mediumRiskPatterns) { + if (pattern.test(name) || pattern.test(symbol)) { + reasons.push(`Suspicious naming pattern: ${pattern.source}`); + riskLevel = 'medium'; + } + } + } + + // Check for suspicious character combinations + if (name.includes('โœ…') && (name.includes('airdrop') || name.includes('distribution'))) { + reasons.push('Checkmark + airdrop/distribution pattern'); + riskLevel = 'high'; + } + + // Check for Cyrillic character impersonation (like Uะ…Dะก vs USDC) + const cyrillicChars = name.match(/[\u0400-\u04FF]/g) || symbol.match(/[\u0400-\u04FF]/g); + if (cyrillicChars) { + reasons.push(`Contains Cyrillic characters: ${cyrillicChars.join(', ')}`); + riskLevel = 'high'; + } + + // Check for URL patterns in name/symbol + if (name.includes('t.me') || symbol.includes('t.me') || name.includes('http') || symbol.includes('http')) { + reasons.push('Contains URL/social media links'); + riskLevel = 'high'; + } + + // Check for excessive special characters + const specialCharCount = (name.match(/[^a-zA-Z0-9\s]/g) || []).length; + if (specialCharCount > 3) { + reasons.push(`Excessive special characters (${specialCharCount})`); + if (riskLevel === 'low') riskLevel = 'medium'; + } + + // Check for missing logo ONLY if there are already other warning signs + const hasOtherWarnings = reasons.length > 0; + if (hasOtherWarnings && !hasTokenLogo) { + reasons.push('No token logo available'); + // Upgrade risk level if missing logo + other warnings + if (riskLevel === 'medium') { + riskLevel = 'high'; + } else if (riskLevel === 'low') { + riskLevel = 'medium'; + } + } + + const isScam = reasons.length > 0; + + return { + isScam, + riskLevel, + reasons + }; +}; + const TokenInfoDialog: React.FC = ({ token, onClose, @@ -54,6 +170,26 @@ const TokenInfoDialog: React.FC = ({ // Check if token is on HyperEVM network const isHyperEvmToken = token.chain === 'hyperevm'; + // Check for scam token indicators + const hasTokenLogo = !!token.logo; + const scamAlert = checkAlertScanToken(token, hasTokenLogo); + + // Collapsible state for security warning + const [isWarningExpanded, setIsWarningExpanded] = useState(false); + + // Log scam detection for debugging + if (scamAlert.isScam) { + console.warn('๐Ÿšจ POTENTIAL SCAM TOKEN IN DIALOG:', { + name: token.name, + symbol: token.symbol, + riskLevel: scamAlert.riskLevel, + reasons: scamAlert.reasons, + hasLogo: hasTokenLogo, + contractAddress: token.contractAddress, + chain: token.chain, + }); + } + // Handle swap button click const handleSwapClick = () => { // Reset swap store to clear any previous state @@ -116,6 +252,52 @@ const TokenInfoDialog: React.FC = ({ + {/* Scam Warning Alert */} + {scamAlert.isScam && ( +
+ {/* Collapsible Header */} +
setIsWarningExpanded(!isWarningExpanded)} + > + +

+ Security Warning - {scamAlert.riskLevel.toUpperCase()} Risk +

+ {isWarningExpanded ? ( + + ) : ( + + )} +
+ + {/* Collapsible Content */} + {isWarningExpanded && ( +
+

Verify this token carefully before transactions.

+
+ )} +
+ )} + {isLoading ? (
@@ -205,6 +387,28 @@ const TokenInfoDialog: React.FC = ({ {/* Actions moved to footer */} + {/* Swap button - only show for HyperEVM tokens */} + {isHyperEvmToken && ( + + )} + {getTokenExplorerUrl( token.chain, token.contractAddress, diff --git a/src/client/components/token-list.tsx b/src/client/components/token-list.tsx index 91da089..7b1062e 100644 --- a/src/client/components/token-list.tsx +++ b/src/client/components/token-list.tsx @@ -3,6 +3,7 @@ import { formatCurrency } from '../utils/formatters'; import { ChainType } from '../types/wallet'; import { getTokenLogo } from '../utils/icons'; import { getNetworkIcon } from '@/utils/network-icons'; +import { AlertTriangle } from 'lucide-react'; interface UnifiedToken { chain: ChainType; @@ -31,6 +32,171 @@ interface TokenItemProps { onClick?: (token: UnifiedToken) => void; } +interface ScamAlert { + isScam: boolean; + riskLevel: 'high' | 'medium' | 'low'; + reasons: string[]; +} + +const checkAlertScanToken = ( + token: UnifiedToken, + hasTokenLogo: boolean = false +): ScamAlert => { + const reasons: string[] = []; + let riskLevel: 'high' | 'medium' | 'low' = 'low'; + + const name = token.name?.toLowerCase() || ''; + const symbol = token.symbol?.toLowerCase() || ''; + + // Debug logging for legitimate tokens that might be flagged + if (['eth', 'usdc', 'usdt', 'arb', 'btc', 'bnb'].includes(symbol.trim())) { + console.log('๐Ÿ” CHECKING LEGITIMATE TOKEN:', { + originalName: token.name, + originalSymbol: token.symbol, + processedName: name, + processedSymbol: symbol, + symbolTrimmed: symbol.trim(), + hasSpaces: symbol !== symbol.trim(), + }); + } + + // High-risk indicators + const highRiskPatterns = [ + // Airdrop/claim scams + /airdrop/i, + /claim/i, + /distribution/i, + /round\s*\d+/i, + /visit.*claim/i, + /claim.*until/i, + + // Telegram/social media indicators + /t\.me/i, + /telegram/i, + /\*visit/i, + /\*claim/i, + + // Suspicious symbols and characters + /[โœ…โšก๐ŸŽ๐Ÿ’ฐ๐Ÿš€]/u, + /[\u0400-\u04FF]/, // Cyrillic characters (like Uะ…Dะก) + /[\u2000-\u206F]/, // General punctuation (invisible chars) + + // Impersonation attempts + /official/i, + /verified/i, + /authentic/i, + ]; + + // Medium-risk indicators + const mediumRiskPatterns = [ + // Suspicious naming patterns + /token.*distribution/i, + /free.*token/i, + /bonus.*token/i, + ]; + + // Common token impersonation with suspicious spacing/characters + // Only flag if the symbol/name is EXACTLY these tokens but with suspicious spacing + const suspiciousTokenPatterns = [ + // Exact matches with leading/trailing spaces or multiple spaces + /^\s+arb\s*$/i, // " ARB" or " ARB " + /^\s*arb\s+$/i, // "ARB " or " ARB " + /^\s+usdc\s*$/i, // " USDC" or " USDC " + /^\s*usdc\s+$/i, // "USDC " or " USDC " + /^\s+usdt\s*$/i, // " USDT" or " USDT " + /^\s*usdt\s+$/i, // "USDT " or " USDT " + /^\s+eth\s*$/i, // " ETH" or " ETH " + /^\s*eth\s+$/i, // "ETH " or " ETH " + /^\s+btc\s*$/i, // " BTC" or " BTC " + /^\s*btc\s+$/i, // "BTC " or " BTC " + /^\s+bnb\s*$/i, // " BNB" or " BNB " + /^\s*bnb\s+$/i, // "BNB " or " BNB " + ]; + + // Check high-risk patterns + for (const pattern of highRiskPatterns) { + if (pattern.test(name) || pattern.test(symbol)) { + reasons.push(`Suspicious pattern detected: ${pattern.source}`); + riskLevel = 'high'; + } + } + + // Check suspicious token impersonation patterns (only if not already high risk) + if (riskLevel !== 'high') { + for (const pattern of suspiciousTokenPatterns) { + if (pattern.test(name) || pattern.test(symbol)) { + reasons.push(`Suspicious token name with spacing/characters`); + riskLevel = 'medium'; + } + } + } + + // Check medium-risk patterns (only if not already high risk) + if (riskLevel !== 'high') { + for (const pattern of mediumRiskPatterns) { + if (pattern.test(name) || pattern.test(symbol)) { + reasons.push(`Suspicious naming pattern: ${pattern.source}`); + riskLevel = 'medium'; + } + } + } + + // Check for suspicious character combinations + if ( + name.includes('โœ…') && + (name.includes('airdrop') || name.includes('distribution')) + ) { + reasons.push('Checkmark + airdrop/distribution pattern'); + riskLevel = 'high'; + } + + // Check for Cyrillic character impersonation (like Uะ…Dะก vs USDC) + const cyrillicChars = + name.match(/[\u0400-\u04FF]/g) || symbol.match(/[\u0400-\u04FF]/g); + if (cyrillicChars) { + reasons.push(`Contains Cyrillic characters: ${cyrillicChars.join(', ')}`); + riskLevel = 'high'; + } + + // Check for URL patterns in name/symbol + if ( + name.includes('t.me') || + symbol.includes('t.me') || + name.includes('http') || + symbol.includes('http') + ) { + reasons.push('Contains URL/social media links'); + riskLevel = 'high'; + } + + // Check for excessive special characters + const specialCharCount = (name.match(/[^a-zA-Z0-9\s]/g) || []).length; + if (specialCharCount > 3) { + reasons.push(`Excessive special characters (${specialCharCount})`); + if (riskLevel === 'low') riskLevel = 'medium'; + } + + // Check for missing logo ONLY if there are already other warning signs + const hasOtherWarnings = reasons.length > 0; + if (hasOtherWarnings && !hasTokenLogo) { + reasons.push('No token logo available'); + // Upgrade risk level if missing logo + other warnings + if (riskLevel === 'medium') { + riskLevel = 'high'; + } else if (riskLevel === 'low') { + riskLevel = 'medium'; + } + } + + const isScam = reasons.length > 0; + + return { + isScam, + riskLevel, + reasons, + }; +}; + const TokenItem = ({ token, onClick }: TokenItemProps) => { // Safely handle potential null/undefined values const safeSymbol = token.symbol || 'UNKNOWN'; @@ -47,6 +213,10 @@ const TokenItem = ({ token, onClick }: TokenItemProps) => { const [tokenImageError, setTokenImageError] = useState(!token.logo); + // Check for scam token indicators + const hasTokenLogo = !tokenImageError && !!tokenLogoSrc; + const scamAlert = checkAlertScanToken(token, hasTokenLogo); + // Load token logo asynchronously if not provided useEffect(() => { if (!token.logo) { @@ -60,7 +230,7 @@ const TokenItem = ({ token, onClick }: TokenItemProps) => { } ); } - }, [token.logo, safeSymbol]); + }, [token.logo, safeSymbol, token.chain, token.contractAddress]); const handleClick = () => { if (onClick) { @@ -68,9 +238,23 @@ const TokenItem = ({ token, onClick }: TokenItemProps) => { } }; + // Get scam warning styles + const getScamWarningStyles = () => { + if (!scamAlert.isScam) return ''; + + switch (scamAlert.riskLevel) { + case 'high': + return 'border-2 border-amber-500/50 bg-amber-500/5'; + case 'medium': + return 'border-2 border-yellow-500/50 bg-yellow-500/5'; + default: + return 'border border-orange-500/30 bg-orange-500/5'; + } + }; + return (
{ onError={() => setTokenImageError(true)} /> ) : ( -
- {safeSymbol.charAt(0).toUpperCase()} +
+ {scamAlert.isScam ? ( + + ) : ( + safeSymbol.charAt(0).toUpperCase() + )}
)}
@@ -104,7 +296,7 @@ const TokenItem = ({ token, onClick }: TokenItemProps) => { )}
-
+
{ {safeSymbol}
+
{formatCurrency(safeValue)}
+ +
); }; diff --git a/src/client/components/ui/index.ts b/src/client/components/ui/index.ts index 3167b29..e2e86b5 100644 --- a/src/client/components/ui/index.ts +++ b/src/client/components/ui/index.ts @@ -19,3 +19,14 @@ export { } from './collapsiable'; export { CircularTimer } from './circular-timer'; +export { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider, +} from './tooltip'; +export type { + TooltipProps, + TooltipTriggerProps, + TooltipContentProps, +} from './tooltip'; diff --git a/src/client/components/ui/tooltip.tsx b/src/client/components/ui/tooltip.tsx new file mode 100644 index 0000000..73144e8 --- /dev/null +++ b/src/client/components/ui/tooltip.tsx @@ -0,0 +1,400 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { cn } from '@/client/lib/utils'; + +interface TooltipProps { + children: React.ReactNode; + delayDuration?: number; + skipDelayDuration?: number; + disableHoverableContent?: boolean; +} + +interface TooltipTriggerProps { + children: React.ReactNode; + asChild?: boolean; + className?: string; +} + +interface TooltipContentProps { + children: React.ReactNode; + className?: string; + side?: 'top' | 'right' | 'bottom' | 'left'; + align?: 'start' | 'center' | 'end'; + sideOffset?: number; + alignOffset?: number; + avoidCollisions?: boolean; + collisionBoundary?: Element | null; + collisionPadding?: + | number + | Partial>; + arrowPadding?: number; + sticky?: 'partial' | 'always'; + hideWhenDetached?: boolean; + onEscapeKeyDown?: (event: KeyboardEvent) => void; + onPointerDownOutside?: (event: PointerEvent) => void; +} + +const TooltipContext = React.createContext<{ + open: boolean; + onOpenChange: (open: boolean) => void; + triggerRef: React.RefObject; + delayDuration: number; + skipDelayDuration: number; + disableHoverableContent: boolean; +}>({ + open: false, + onOpenChange: () => {}, + triggerRef: { current: null }, + delayDuration: 700, + skipDelayDuration: 300, + disableHoverableContent: false, +}); + +const Tooltip = React.forwardRef( + ( + { + children, + delayDuration = 700, + skipDelayDuration = 300, + disableHoverableContent = false, + ...props + }, + ref + ) => { + const [open, setOpen] = React.useState(false); + const triggerRef = React.useRef(null); + const timeoutRef = React.useRef(null); + const skipTimeoutRef = React.useRef(null); + const [skipDelay, setSkipDelay] = React.useState(false); + + const handleOpenChange = React.useCallback( + (newOpen: boolean) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + if (newOpen) { + const delay = skipDelay ? skipDelayDuration : delayDuration; + timeoutRef.current = setTimeout(() => { + setOpen(true); + }, delay); + } else { + setOpen(false); + // Set skip delay for next tooltip + setSkipDelay(true); + if (skipTimeoutRef.current) { + clearTimeout(skipTimeoutRef.current); + } + skipTimeoutRef.current = setTimeout(() => { + setSkipDelay(false); + }, skipDelayDuration); + } + }, + [delayDuration, skipDelayDuration, skipDelay] + ); + + React.useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + if (skipTimeoutRef.current) { + clearTimeout(skipTimeoutRef.current); + } + }; + }, []); + + return ( + +
+ {children} +
+
+ ); + } +); +Tooltip.displayName = 'Tooltip'; + +const TooltipTrigger = React.forwardRef( + ({ children, asChild = false, className, ...props }, ref) => { + const { onOpenChange, triggerRef } = React.useContext(TooltipContext); + + const handleMouseEnter = () => { + onOpenChange(true); + }; + + const handleMouseLeave = () => { + onOpenChange(false); + }; + + const handleFocus = () => { + onOpenChange(true); + }; + + const handleBlur = () => { + onOpenChange(false); + }; + + if (asChild) { + const child = children as React.ReactElement; + const combinedRef = (node: HTMLElement | null) => { + triggerRef.current = node; + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }; + + return React.cloneElement(child, { + ref: combinedRef, + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + onFocus: handleFocus, + onBlur: handleBlur, + } as React.HTMLAttributes); + } + + return ( + + ); + } +); +TooltipTrigger.displayName = 'TooltipTrigger'; + +const TooltipContent = React.forwardRef( + ( + { + children, + className, + side = 'top', + align = 'center', + sideOffset = 4, + alignOffset = 0, + avoidCollisions = true, + collisionPadding = 10, + onEscapeKeyDown, + ...props + }, + ref + ) => { + const { open, onOpenChange, triggerRef } = React.useContext(TooltipContext); + const contentRef = React.useRef(null); + const [position, setPosition] = React.useState({ top: 0, left: 0 }); + const [actualSide, setActualSide] = React.useState(side); + + // Calculate position based on trigger element + const updatePosition = React.useCallback(() => { + if (!triggerRef.current || !contentRef.current) return; + + const triggerRect = triggerRef.current.getBoundingClientRect(); + const contentRect = contentRef.current.getBoundingClientRect(); + const viewport = { + width: window.innerWidth, + height: window.innerHeight, + }; + + let top = 0; + let left = 0; + let finalSide = side; + + // Calculate initial position based on preferred side + switch (side) { + case 'top': + top = triggerRect.top - contentRect.height - sideOffset; + break; + case 'bottom': + top = triggerRect.bottom + sideOffset; + break; + case 'left': + left = triggerRect.left - contentRect.width - sideOffset; + break; + case 'right': + left = triggerRect.right + sideOffset; + break; + } + + // Handle collision detection and flipping + if (avoidCollisions) { + const padding = + typeof collisionPadding === 'number' ? collisionPadding : 10; + + if (side === 'top' && top < padding) { + // Flip to bottom + top = triggerRect.bottom + sideOffset; + finalSide = 'bottom'; + } else if ( + side === 'bottom' && + top + contentRect.height > viewport.height - padding + ) { + // Flip to top + top = triggerRect.top - contentRect.height - sideOffset; + finalSide = 'top'; + } else if (side === 'left' && left < padding) { + // Flip to right + left = triggerRect.right + sideOffset; + finalSide = 'right'; + } else if ( + side === 'right' && + left + contentRect.width > viewport.width - padding + ) { + // Flip to left + left = triggerRect.left - contentRect.width - sideOffset; + finalSide = 'left'; + } + } + + // Calculate alignment + if (finalSide === 'top' || finalSide === 'bottom') { + switch (align) { + case 'start': + left = triggerRect.left + alignOffset; + break; + case 'center': + left = + triggerRect.left + + triggerRect.width / 2 - + contentRect.width / 2 + + alignOffset; + break; + case 'end': + left = triggerRect.right - contentRect.width + alignOffset; + break; + } + } else { + switch (align) { + case 'start': + top = triggerRect.top + alignOffset; + break; + case 'center': + top = + triggerRect.top + + triggerRect.height / 2 - + contentRect.height / 2 + + alignOffset; + break; + case 'end': + top = triggerRect.bottom - contentRect.height + alignOffset; + break; + } + } + + // Final boundary checks + const padding = + typeof collisionPadding === 'number' ? collisionPadding : 10; + left = Math.max( + padding, + Math.min(left, viewport.width - contentRect.width - padding) + ); + top = Math.max( + padding, + Math.min(top, viewport.height - contentRect.height - padding) + ); + + setPosition({ top, left }); + setActualSide(finalSide); + }, [ + side, + align, + sideOffset, + alignOffset, + avoidCollisions, + collisionPadding, + triggerRef, + ]); + + // Update position when open + React.useEffect(() => { + if (open) { + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition); + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition); + }; + } + }, [open, updatePosition]); + + // Handle escape key + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onEscapeKeyDown?.(event); + if (!event.defaultPrevented) { + onOpenChange(false); + } + } + }; + + if (open) { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + } + }, [open, onOpenChange, onEscapeKeyDown]); + + if (!open) return null; + + const content = ( +
{ + contentRef.current = node; + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }} + className={cn( + 'fixed z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md', + 'animate-in fade-in-0 zoom-in-95', + actualSide === 'bottom' && 'slide-in-from-top-2', + actualSide === 'left' && 'slide-in-from-right-2', + actualSide === 'right' && 'slide-in-from-left-2', + actualSide === 'top' && 'slide-in-from-bottom-2', + className + )} + style={{ + top: position.top, + left: position.left, + }} + data-side={actualSide} + {...props} + > + {children} +
+ ); + + return createPortal(content, document.body); + } +); +TooltipContent.displayName = 'TooltipContent'; + +const TooltipProvider = ({ children }: { children: React.ReactNode }) => { + return <>{children}; +}; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; +export type { TooltipProps, TooltipTriggerProps, TooltipContentProps }; diff --git a/src/client/hooks/README-transaction-cache.md b/src/client/hooks/README-transaction-cache.md new file mode 100644 index 0000000..d1a7e0e --- /dev/null +++ b/src/client/hooks/README-transaction-cache.md @@ -0,0 +1,255 @@ +# Transaction Cache Hook + +A smart caching layer for Ethereum transaction fetching that dramatically improves performance by only fetching new transactions from the last cached block. + +## Features + +- ๐Ÿš€ **Smart Incremental Fetching**: Only fetches new transactions from the last cached block +- โšก **Instant Loading**: Returns cached data immediately if fresh (within 5 minutes) +- ๐Ÿ”„ **Seamless Updates**: Automatically appends new transactions to existing cache +- ๐Ÿ’พ **Storage Efficient**: Limits cache to 5000 transactions per chain to prevent bloat +- ๐Ÿ”ง **Backward Compatible**: Can be disabled to use original fetching logic +- ๐ŸŒ **Multi-Chain Support**: Works with all supported chains (Ethereum, Arbitrum, Base, HyperEVM) + +## Quick Start + +### Replace Existing Hooks + +Simply replace your existing transaction hooks with the cached versions: + +```typescript +// Before +import { useInfiniteTransactions } from '@/client/hooks/use-etherscan-transactions'; + +// After +import { useCachedInfiniteTransactions } from '@/client/hooks/use-transaction-cache'; +``` + +### Basic Usage + +```typescript +import { useCachedTransactions, useCachedInfiniteTransactions } from '@/client/hooks/use-transaction-cache'; + +// Single chain with caching +const { data, isLoading, error } = useCachedTransactions( + address, + 1, // Ethereum mainnet + { + enabled: !!address, + enableCache: true, // Enable caching + sort: 'desc', + offset: 100, + } +); + +// Multi-chain with caching +const { + data, + fetchNextPage, + hasNextPage, + isLoading, + error +} = useCachedInfiniteTransactions( + address, + [1, 42161, 8453, 999], // Multiple chains + { + enabled: !!address, + enableCache: true, // Enable caching + sort: 'desc', + offset: 50, + } +); +``` + +## API Reference + +### `useCachedTransactions` + +Single-chain transaction fetching with caching. + +```typescript +useCachedTransactions( + address: string, + chainId: number, + options?: UseInfiniteTransactionsOptions & { enableCache?: boolean } +) +``` + +**Parameters:** +- `address`: Wallet address to fetch transactions for +- `chainId`: Chain ID (1 = Ethereum, 42161 = Arbitrum, etc.) +- `options`: Configuration options + - `enableCache`: Enable/disable caching (default: true) + - `enabled`: Enable/disable the query (default: true) + - `sort`: Sort order 'asc' | 'desc' (default: 'asc') + - `offset`: Number of transactions per page (default: 1000) + +**Returns:** +- `data`: TransactionPage with transactions array +- `isLoading`: Loading state +- `error`: Error state +- Standard React Query return values + +### `useCachedInfiniteTransactions` + +Multi-chain transaction fetching with infinite scroll and caching. + +```typescript +useCachedInfiniteTransactions( + address: string, + chainIds: number[], + options?: UseInfiniteTransactionsOptions & { enableCache?: boolean } +) +``` + +**Parameters:** +- `address`: Wallet address to fetch transactions for +- `chainIds`: Array of chain IDs to fetch from +- `options`: Same as `useCachedTransactions` + +**Returns:** +- `data`: Infinite query data with pages +- `fetchNextPage`: Function to load more data +- `hasNextPage`: Boolean indicating if more data is available +- `isLoading`: Loading state +- `error`: Error state +- Standard React Query infinite return values + +## Cache Management + +### `TransactionCacheLib` + +Low-level cache management utilities: + +```typescript +import { TransactionCacheLib } from '@/client/hooks/use-transaction-cache'; + +// Get cached transactions +const cached = await TransactionCacheLib.getCachedTransactions(address, chainId); + +// Cache transactions manually +await TransactionCacheLib.cacheTransactions(address, chainId, transactions, lastBlock); + +// Append new transactions +await TransactionCacheLib.appendTransactions(address, chainId, newTransactions, newLastBlock); + +// Clear cache +await TransactionCacheLib.clearCache(address, chainId); +``` + +## How It Works + +### 1. First Load +- Checks for existing cached data +- If no cache exists, fetches from block 0 +- Caches all transactions with the last block number + +### 2. Subsequent Loads +- Checks cache freshness (5-minute expiry) +- If fresh, returns cached data immediately +- If stale, fetches only new transactions from last cached block + 1 +- Merges new transactions with cached data +- Updates cache with new last block number + +### 3. Cache Storage +- Uses Chrome extension storage API +- Stores up to 5000 transactions per chain per address +- Automatically removes oldest transactions when limit is reached +- Deduplicates transactions by hash + +## Configuration + +### Cache Settings + +```typescript +// In use-transaction-cache.ts +const TRANSACTION_CACHE_KEY = 'purro:transaction-cache'; +const MAX_TRANSACTIONS_PER_CHAIN = 5000; // Limit per chain +const CACHE_EXPIRY_TIME = 5 * 60 * 1000; // 5 minutes +``` + +### Disable Caching + +To use original behavior without caching: + +```typescript +const { data } = useCachedTransactions(address, chainId, { + enableCache: false, // Disables caching +}); +``` + +## Migration Guide + +### From `useInfiniteTransactions` + +```typescript +// Before +const { data, fetchNextPage, hasNextPage } = useInfiniteTransactions( + address, + chainIds, + { sort: 'desc', offset: 100 } +); + +// After +const { data, fetchNextPage, hasNextPage } = useCachedInfiniteTransactions( + address, + chainIds, + { + sort: 'desc', + offset: 100, + enableCache: true // Add this line + } +); +``` + +### From `useTransactions` + +```typescript +// Before +const { data } = useTransactions(address, chainId, { + sort: 'desc', + offset: 100 +}); + +// After +const { data } = useCachedTransactions(address, chainId, { + sort: 'desc', + offset: 100, + enableCache: true // Add this line +}); +``` + +## Performance Benefits + +- **Reduced API Calls**: Only fetches new transactions, not entire history +- **Faster Loading**: Cached data loads instantly +- **Lower Rate Limiting**: Fewer API requests means less chance of hitting rate limits +- **Better UX**: Users see data immediately while new data loads in background + +## Best Practices + +1. **Enable caching by default** for better performance +2. **Use appropriate cache expiry** (5 minutes is good for most use cases) +3. **Handle loading states** properly for better UX +4. **Clear cache when needed** (e.g., when switching accounts) +5. **Monitor storage usage** in development + +## Troubleshooting + +### Cache Not Working +- Check if `enableCache: true` is set +- Verify Chrome storage permissions +- Check browser console for errors + +### Stale Data +- Cache expires after 5 minutes automatically +- Clear cache manually if needed: `TransactionCacheLib.clearCache(address, chainId)` + +### Storage Issues +- Cache is limited to 5000 transactions per chain +- Oldest transactions are automatically removed +- Clear all cache: `TransactionCacheLib.clearCache(address)` + +## Example Implementation + +See `use-transaction-cache-example.tsx` for a complete working example showing all features and usage patterns. diff --git a/src/client/hooks/cache-logging-guide.md b/src/client/hooks/cache-logging-guide.md new file mode 100644 index 0000000..11ab43c --- /dev/null +++ b/src/client/hooks/cache-logging-guide.md @@ -0,0 +1,243 @@ +# Transaction Cache Logging Guide + +Now you can see exactly when the cache is working! Open your browser's Developer Console (F12) and look for these log messages: + +## ๐Ÿ” **How to View Logs** + +1. **Open Developer Tools**: Press `F12` or right-click โ†’ "Inspect" +2. **Go to Console Tab**: Click on "Console" tab +3. **Navigate to History**: Go to the transaction history screen +4. **Watch the Logs**: You'll see detailed cache behavior logs + +## ๐Ÿ“‹ **Log Types & What They Mean** + +### โšก **CACHE HIT** - Data Loaded from Cache +``` +โšก CACHE HIT: Using cached transactions +{ + address: "0x1234...", + chainId: 1, + cachedTransactions: 150, + lastBlock: "18500000", + cacheAge: "45s", + timestamp: "2:30:15 PM" +} +``` +**Meaning**: Found fresh cached data (< 5 minutes old), loading instantly without API call. + +### ๐Ÿ”„ **CACHE MISS** - Fetching New Data +``` +๐Ÿ”„ CACHE MISS: Fetching new transactions +{ + address: "0x1234...", + chainId: 1, + startBlock: "18500001", + hasCachedData: true, + cachedTransactions: 150, + lastCachedBlock: "18500000", + cacheAge: "6m", + timestamp: "2:30:15 PM" +} +``` +**Meaning**: Cache is stale (> 5 minutes) or no cache exists, fetching only new transactions from last cached block. + +### โœ… **NEW TRANSACTIONS FOUND** +``` +โœ… NEW TRANSACTIONS FOUND: +{ + address: "0x1234...", + chainId: 1, + newTransactions: 25, + newLastBlock: "18500025", + fromBlock: "18500001", + timestamp: "2:30:16 PM" +} +``` +**Meaning**: Found new transactions since last cache, will append to existing cache. + +### ๐Ÿ”„ **CACHE UPDATED** +``` +๐Ÿ”„ CACHE UPDATED: Appended new transactions +{ + address: "0x1234...", + chainId: 1, + newTransactions: 25, + totalCached: 175, + timestamp: "2:30:16 PM" +} +``` +**Meaning**: Successfully merged new transactions with existing cache. + +### ๐Ÿ’พ **CACHE CREATED** +``` +๐Ÿ’พ CACHE CREATED: First time caching +{ + address: "0x1234...", + chainId: 1, + transactions: 100, + lastBlock: "18500000", + timestamp: "2:30:15 PM" +} +``` +**Meaning**: No previous cache existed, created new cache with fetched transactions. + +### ๐Ÿ“‹ **NO NEW TRANSACTIONS** +``` +๐Ÿ“‹ NO NEW TRANSACTIONS: Using existing cache +{ + address: "0x1234...", + chainId: 1, + cachedTransactions: 150, + lastBlock: "18500000", + timestamp: "2:30:15 PM" +} +``` +**Meaning**: No new transactions since last cache, returning existing cached data. + +## ๐ŸŒ **Multi-Chain Logs** + +### โšก **Multi-Chain Cache Hit** +``` +โšก MULTI-CHAIN CACHE HIT [Chain 1]: +{ + address: "0x1234...", + chainId: 1, + cachedTransactions: 150, + lastBlock: "18500000", + cacheAge: "45s" +} +``` + +### ๐Ÿ”„ **Multi-Chain Incremental Fetch** +``` +๐Ÿ”„ MULTI-CHAIN INCREMENTAL [Chain 42161]: +{ + address: "0x1234...", + chainId: 42161, + startBlock: "150000001", + cachedTransactions: 75, + lastCachedBlock: "150000000" +} +``` + +### ๐Ÿ†• **Multi-Chain First Fetch** +``` +๐Ÿ†• MULTI-CHAIN FIRST FETCH [Chain 8453]: +{ + address: "0x1234...", + chainId: 8453, + startBlock: "0" +} +``` + +### ๐Ÿ“Š **Multi-Chain Summary** +``` +๐Ÿ“Š MULTI-CHAIN SUMMARY: +{ + address: "0x1234...", + totalChains: 4, + chainsWithData: 3, + totalTransactions: 325, + results: [ + { chainId: 1, transactions: 150, hasMore: false }, + { chainId: 42161, transactions: 75, hasMore: true }, + { chainId: 8453, transactions: 100, hasMore: false }, + { chainId: 999, transactions: 0, hasMore: false } + ], + timestamp: "2:30:16 PM" +} +``` + +## ๐Ÿ”„ **History Screen Summary Log** +``` +๐Ÿ”„ Transaction Cache Status: +{ + address: "0x1234...", + chains: [1, 42161, 8453, 999], + totalTransactions: 325, + pages: 1, + timestamp: "2:30:16 PM", + cacheEnabled: true +} +``` + +## ๐Ÿงช **Testing Cache Behavior** + +### **Test 1: First Load (No Cache)** +1. Clear browser cache or use new address +2. Navigate to history +3. **Expected logs**: `CACHE MISS` โ†’ `NEW TRANSACTIONS FOUND` โ†’ `CACHE CREATED` + +### **Test 2: Second Load (Fresh Cache)** +1. Refresh page within 5 minutes +2. Navigate to history +3. **Expected logs**: `CACHE HIT` (instant loading) + +### **Test 3: Stale Cache** +1. Wait 6+ minutes or manually set old timestamp +2. Navigate to history +3. **Expected logs**: `CACHE MISS` โ†’ `NEW TRANSACTIONS FOUND` โ†’ `CACHE UPDATED` + +### **Test 4: No New Transactions** +1. Load history twice quickly +2. **Expected logs**: `CACHE MISS` โ†’ `NO NEW TRANSACTIONS` + +## ๐ŸŽฏ **Performance Indicators** + +### **Good Performance Signs**: +- โšก Frequent `CACHE HIT` messages +- ๐Ÿ”„ `CACHE UPDATED` with small numbers of new transactions +- ๐Ÿ“‹ `NO NEW TRANSACTIONS` messages +- Fast loading times + +### **Expected Behavior**: +- **First visit**: Full fetch from block 0 +- **Within 5 minutes**: Instant cache hit +- **After 5 minutes**: Incremental fetch from last block +- **No new activity**: Return cached data + +## ๐Ÿ› ๏ธ **Troubleshooting** + +### **If you don't see cache hits**: +- Check if `enableCache: true` is set +- Verify browser storage permissions +- Look for error messages in console + +### **If cache seems broken**: +- Clear cache manually: `TransactionCacheLib.clearCache(address)` +- Check for storage quota exceeded errors +- Verify network connectivity + +## ๐Ÿ“ˆ **Performance Comparison** + +### **Without Cache** (Original): +``` +๐Ÿ”„ Fetching 1000 transactions from block 0... +โฑ๏ธ API call: 2.5s +๐Ÿ“Š Total: 1000 transactions +``` + +### **With Cache** (First Load): +``` +๐Ÿ”„ CACHE MISS: Fetching new transactions (first time) +โฑ๏ธ API call: 2.5s +๐Ÿ’พ CACHE CREATED: 1000 transactions +๐Ÿ“Š Total: 1000 transactions +``` + +### **With Cache** (Subsequent Loads): +``` +โšก CACHE HIT: Using cached transactions +โฑ๏ธ Load time: <100ms +๐Ÿ“Š Total: 1000 transactions (from cache) +``` + +### **With Cache** (After New Activity): +``` +๐Ÿ”„ CACHE MISS: Fetching new transactions +โฑ๏ธ API call: 0.3s (only 50 new transactions) +๐Ÿ”„ CACHE UPDATED: 1050 total transactions +๐Ÿ“Š Total: 1050 transactions (1000 cached + 50 new) +``` + +The logs clearly show the dramatic performance improvement from caching! ๐Ÿš€ diff --git a/src/client/hooks/history-integration-example.md b/src/client/hooks/history-integration-example.md new file mode 100644 index 0000000..e0dabdb --- /dev/null +++ b/src/client/hooks/history-integration-example.md @@ -0,0 +1,203 @@ +# Integrating Transaction Cache with History Screen + +This guide shows how to update the existing history screen to use the new cached transaction hook for better performance. + +## Current Implementation + +The history screen currently uses: +```typescript +import { useInfiniteTransactions } from '@/client/hooks/use-etherscan-transactions'; + +const { data, fetchNextPage, hasNextPage, isLoading, error } = + useInfiniteTransactions(address || '', activeChainIds, { + enabled: !!address, + sort: 'desc', + offset: 100, + }); +``` + +## Updated Implementation + +Simply replace the import and add the `enableCache` option: + +```typescript +// Change this import +import { useInfiniteTransactions } from '@/client/hooks/use-etherscan-transactions'; + +// To this import +import { useCachedInfiniteTransactions } from '@/client/hooks/use-transaction-cache'; + +// Update the hook call +const { data, fetchNextPage, hasNextPage, isLoading, error } = + useCachedInfiniteTransactions(address || '', activeChainIds, { + enabled: !!address, + sort: 'desc', + offset: 100, + enableCache: true, // Add this line to enable caching + }); +``` + +## Complete Integration Steps + +### Step 1: Update the Import + +In `src/client/screens/main/main-screens/history/index.tsx`: + +```typescript +// Line 5: Replace this +import { useInfiniteTransactions } from '@/client/hooks/use-etherscan-transactions'; + +// With this +import { useCachedInfiniteTransactions } from '@/client/hooks/use-transaction-cache'; +``` + +### Step 2: Update the Hook Call + +```typescript +// Lines 595-600: Replace this +const { data, fetchNextPage, hasNextPage, isLoading, error } = + useInfiniteTransactions(address || '', activeChainIds, { + enabled: !!address, + sort: 'desc', // Most recent first + offset: 100, + }); + +// With this +const { data, fetchNextPage, hasNextPage, isLoading, error } = + useCachedInfiniteTransactions(address || '', activeChainIds, { + enabled: !!address, + sort: 'desc', // Most recent first + offset: 100, + enableCache: true, // Enable caching for better performance + }); +``` + +### Step 3: Optional - Add Cache Status Indicator + +You can optionally add a visual indicator to show when data is being loaded from cache vs fresh API calls: + +```typescript +// Add this state +const [cacheStatus, setCacheStatus] = useState<'loading' | 'cached' | 'fresh'>('loading'); + +// Add this effect to track cache status +useEffect(() => { + if (isLoading) { + setCacheStatus('loading'); + } else if (data) { + // You can determine if data came from cache based on loading time + // This is a simple heuristic - very fast loads are likely from cache + setCacheStatus('cached'); + } +}, [isLoading, data]); + +// Add this to your JSX (optional) +{cacheStatus === 'cached' && ( +
+ โšก Loaded from cache +
+)} +``` + +## Benefits After Integration + +### Performance Improvements +- **Faster Initial Load**: Cached transactions load instantly +- **Reduced API Calls**: Only fetches new transactions since last cache +- **Better Rate Limiting**: Fewer API requests means less chance of hitting limits + +### User Experience +- **Instant Results**: Users see transaction history immediately +- **Smoother Scrolling**: Infinite scroll works faster with cached data +- **Reduced Loading**: Less time spent waiting for API responses + +### Technical Benefits +- **Backward Compatible**: Can be disabled by setting `enableCache: false` +- **Automatic Management**: Cache handles deduplication and storage limits +- **Multi-Chain Aware**: Works seamlessly with all supported chains + +## Testing the Integration + +### 1. Test Cache Behavior +```typescript +// First load - should fetch from API and cache +// Subsequent loads within 5 minutes - should load from cache instantly +// After 5 minutes - should fetch only new transactions and update cache +``` + +### 2. Test Cache Disable +```typescript +// Set enableCache: false to verify original behavior still works +const { data } = useCachedInfiniteTransactions(address, chainIds, { + enableCache: false, // Should behave exactly like original hook +}); +``` + +### 3. Test Multi-Chain +```typescript +// Verify that each chain maintains its own cache +// Switch between different chain filters to test cache per chain +``` + +## Rollback Plan + +If any issues arise, you can easily rollback by: + +1. Reverting the import back to the original +2. Removing the `enableCache: true` option +3. The rest of the code remains unchanged + +```typescript +// Rollback: Change back to original +import { useInfiniteTransactions } from '@/client/hooks/use-etherscan-transactions'; + +const { data, fetchNextPage, hasNextPage, isLoading, error } = + useInfiniteTransactions(address || '', activeChainIds, { + enabled: !!address, + sort: 'desc', + offset: 100, + // Remove enableCache option + }); +``` + +## Monitoring and Debugging + +### Cache Statistics +```typescript +import { TransactionCacheLib } from '@/client/hooks/use-transaction-cache'; + +// Get cache stats for debugging +const stats = await TransactionCacheLib.getCacheStats(); +console.log('Cache stats:', stats); +``` + +### Clear Cache for Testing +```typescript +// Clear cache for specific address/chain +await TransactionCacheLib.clearCache(address, chainId); + +// Clear all cache +await TransactionCacheLib.clearCache(address); +``` + +### Debug Cache Behavior +```typescript +// Check if data exists in cache +const cached = await TransactionCacheLib.getCachedTransactions(address, chainId); +console.log('Cached data:', cached); +``` + +## Expected Results + +After integration, you should see: +- History screen loads much faster on subsequent visits +- Smooth infinite scrolling with cached data +- Reduced network activity in browser dev tools +- Better performance especially for users with many transactions + +The cache will automatically handle: +- Fetching only new transactions +- Merging with existing cache +- Deduplication of transactions +- Storage limit management +- Cache expiry and refresh