diff --git a/src/components/web3/DeFiInterface.tsx b/src/components/web3/DeFiInterface.tsx index fa6982a..c84ee64 100644 --- a/src/components/web3/DeFiInterface.tsx +++ b/src/components/web3/DeFiInterface.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useState, useEffect } from 'react'; import { TrendingUp, Lock, Loader2, ArrowUpRight, Zap, Info, Check } from 'lucide-react'; import { useWeb3Wallet } from '@/hooks/useWeb3Wallet'; import { InvestmentSearchBar, InvestmentItem } from './InvestmentSearchBar'; +import { walletCache, walletCacheKeys, CACHE_TTL } from '@/utils/web3/walletCache'; interface StakingPosition { id: string; @@ -89,6 +90,14 @@ export const DeFiInterface: React.FC = ({ setStakingPositions([]); return; } + + const cacheKey = walletCacheKeys.defiPositions(wallet.address); + const cached = walletCache.get(cacheKey); + if (cached) { + setStakingPositions(cached); + return; + } + setIsLoading(true); try { const positions: StakingPosition[] = [ @@ -105,6 +114,7 @@ export const DeFiInterface: React.FC = ({ }, ]; await new Promise((resolve) => setTimeout(resolve, 300)); + walletCache.set(cacheKey, positions, CACHE_TTL.DEFI_POSITIONS); setStakingPositions(positions); } catch (error) { console.error('[DeFiInterface] Error fetching positions:', error); @@ -134,7 +144,17 @@ export const DeFiInterface: React.FC = ({ startDate: Date.now(), endDate: Date.now() + duration * 24 * 60 * 60 * 1000, }; - setStakingPositions((prev) => [...prev, newPosition]); + setStakingPositions((prev) => { + const updated = [...prev, newPosition]; + if (wallet.address) { + walletCache.set( + walletCacheKeys.defiPositions(wallet.address), + updated, + CACHE_TTL.DEFI_POSITIONS, + ); + } + return updated; + }); onStake?.(selectedProtocol.id, stakeAmount, duration); setStakeAmount(''); setSelectedProtocol(null); @@ -144,19 +164,29 @@ export const DeFiInterface: React.FC = ({ } finally { setIsStaking(false); } - }, [selectedProtocol, stakeAmount, stakeDuration, onStake]); + }, [selectedProtocol, stakeAmount, stakeDuration, onStake, wallet.address]); const handleUnstake = useCallback( async (positionId: string) => { try { await new Promise((resolve) => setTimeout(resolve, 800)); - setStakingPositions((prev) => prev.filter((p) => p.id !== positionId)); + setStakingPositions((prev) => { + const updated = prev.filter((p) => p.id !== positionId); + if (wallet.address) { + walletCache.set( + walletCacheKeys.defiPositions(wallet.address), + updated, + CACHE_TTL.DEFI_POSITIONS, + ); + } + return updated; + }); onUnstake?.(positionId); } catch (error) { console.error('[DeFiInterface] Unstaking failed:', error); } }, - [onUnstake], + [onUnstake, wallet.address], ); const investmentItems = React.useMemo(() => { diff --git a/src/components/web3/NFTGallery.tsx b/src/components/web3/NFTGallery.tsx index c3d42e9..dd21980 100644 --- a/src/components/web3/NFTGallery.tsx +++ b/src/components/web3/NFTGallery.tsx @@ -13,6 +13,7 @@ import { List, } from 'lucide-react'; import { useWeb3Wallet } from '@/hooks/useWeb3Wallet'; +import { walletCache, walletCacheKeys, CACHE_TTL } from '@/utils/web3/walletCache'; interface NFT { id: string; @@ -81,6 +82,13 @@ export const NFTGallery: React.FC = ({ return; } + const cacheKey = walletCacheKeys.nfts(wallet.address, wallet.chainId || '0x1'); + const cached = walletCache.get(cacheKey); + if (cached) { + setNfts(cached); + return; + } + setIsLoading(true); setError(null); @@ -134,6 +142,7 @@ export const NFTGallery: React.FC = ({ // Simulate API delay await new Promise((resolve) => setTimeout(resolve, 500)); + walletCache.set(cacheKey, mockNFTs, CACHE_TTL.NFT); setNfts(mockNFTs); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to fetch NFTs'; diff --git a/src/hooks/useWeb3Wallet.ts b/src/hooks/useWeb3Wallet.ts index 1bbfaf0..99060fc 100644 --- a/src/hooks/useWeb3Wallet.ts +++ b/src/hooks/useWeb3Wallet.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { validateWalletInteraction, safeWalletCall } from '@/utils/web3/walletValidation'; +import { walletCache, walletCacheKeys, CACHE_TTL } from '@/utils/web3/walletCache'; /** * Supported wallet providers @@ -261,19 +262,68 @@ export function useWeb3Wallet() { [connectMetaMask, connectStarknet, connectServiceAccount], ); + /** + * Fetch native balance for the connected wallet and cache the result. + * Only runs for MetaMask (EVM) connections; Starknet has a different API. + */ + const fetchBalance = useCallback(async () => { + if (!state.address || !state.chainId || state.provider !== 'metamask') return; + + const cacheKey = walletCacheKeys.balance(state.address, state.chainId); + const cached = walletCache.get(cacheKey); + if (cached) { + setState((prev) => ({ ...prev, balances: cached })); + return; + } + + try { + if (typeof window === 'undefined') return; + const ethereum = (window as Window & { ethereum?: any }).ethereum; + if (!ethereum) return; + + const balanceHex: string = await ethereum.request({ + method: 'eth_getBalance', + params: [state.address, 'latest'], + }); + + const balanceWei = BigInt(balanceHex); + const balanceEth = Number(balanceWei) / 1e18; + const chain = SUPPORTED_CHAINS[state.chainId]; + + const balances: WalletBalance[] = [ + { + token: 'native', + balance: balanceEth.toFixed(6), + decimals: 18, + symbol: chain?.nativeCurrency.symbol ?? 'ETH', + }, + ]; + + walletCache.set(cacheKey, balances, CACHE_TTL.BALANCE); + setState((prev) => ({ ...prev, balances })); + } catch (error) { + console.warn('[useWeb3Wallet] Balance fetch failed:', error); + } + }, [state.address, state.chainId, state.provider]); + /** * Disconnect wallet */ const disconnect = useCallback(async () => { - setState((prev) => ({ - ...prev, - address: null, - isConnected: false, - provider: null, - chainId: null, - balances: [], - error: null, - })); + setState((prev) => { + if (prev.address) { + walletCache.invalidateByPrefix(walletCacheKeys.addressPrefix(prev.address)); + } + return { + ...prev, + address: null, + isConnected: false, + provider: null, + chainId: null, + balances: [], + error: null, + }; + }); if (typeof localStorage !== 'undefined') { localStorage.removeItem('wallet_connected'); @@ -377,6 +427,13 @@ export function useWeb3Wallet() { setState((prev) => ({ ...prev, error: null })); }, []); + /** + * Fetch balance whenever the connected address or chain changes + */ + useEffect(() => { + fetchBalance(); + }, [fetchBalance]); + /** * Auto-connect on mount if previously connected */ @@ -470,6 +527,7 @@ export function useWeb3Wallet() { switchChain, signMessage, sendTransaction, + fetchBalance, clearError, supportedChains: SUPPORTED_CHAINS, }; diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 632da44..6c66bd5 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -25,4 +25,12 @@ export const queryKeys = { all: ['notes'] as const, byLesson: (lessonId: string) => ['notes', lessonId] as const, }, + wallet: { + all: ['wallet'] as const, + balance: (address: string, chainId: string) => + ['wallet', 'balance', address, chainId] as const, + nfts: (address: string, chainId: string) => ['wallet', 'nfts', address, chainId] as const, + positions: (address: string) => ['wallet', 'positions', address] as const, + transactions: (address: string) => ['wallet', 'transactions', address] as const, + }, } as const; diff --git a/src/utils/__tests__/walletCache.test.ts b/src/utils/__tests__/walletCache.test.ts new file mode 100644 index 0000000..74dc539 --- /dev/null +++ b/src/utils/__tests__/walletCache.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { WalletCache, walletCache, walletCacheKeys, CACHE_TTL } from '../web3/walletCache'; + +describe('WalletCache', () => { + let cache: WalletCache; + + beforeEach(() => { + cache = new WalletCache(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('set / get', () => { + it('returns null for a missing key', () => { + expect(cache.get('missing')).toBeNull(); + }); + + it('returns cached data before TTL expires', () => { + cache.set('key', { value: 42 }, 5000); + expect(cache.get('key')).toEqual({ value: 42 }); + }); + + it('returns null after TTL expires', () => { + cache.set('key', 'data', 1000); + vi.advanceTimersByTime(1001); + expect(cache.get('key')).toBeNull(); + }); + + it('overwrites an existing entry', () => { + cache.set('key', 'first', 5000); + cache.set('key', 'second', 5000); + expect(cache.get('key')).toBe('second'); + }); + }); + + describe('has', () => { + it('returns false for a missing key', () => { + expect(cache.has('x')).toBe(false); + }); + + it('returns true for a live key', () => { + cache.set('x', 1, 5000); + expect(cache.has('x')).toBe(true); + }); + + it('returns false for an expired key', () => { + cache.set('x', 1, 500); + vi.advanceTimersByTime(501); + expect(cache.has('x')).toBe(false); + }); + }); + + describe('delete', () => { + it('removes the entry so get returns null', () => { + cache.set('key', 'val', 5000); + cache.delete('key'); + expect(cache.get('key')).toBeNull(); + }); + + it('is a no-op for non-existent keys', () => { + expect(() => cache.delete('ghost')).not.toThrow(); + }); + }); + + describe('clear', () => { + it('removes all entries', () => { + cache.set('a', 1, 5000); + cache.set('b', 2, 5000); + cache.clear(); + expect(cache.get('a')).toBeNull(); + expect(cache.get('b')).toBeNull(); + expect(cache.size()).toBe(0); + }); + }); + + describe('invalidateByPrefix', () => { + it('removes only entries matching the prefix', () => { + cache.set('nfts:0xabc:0x1', ['nft1'], 5000); + cache.set('nfts:0xabc:0x89', ['nft2'], 5000); + cache.set('balance:0xabc:0x1', ['bal'], 5000); + + cache.invalidateByPrefix('nfts:0xabc:'); + + expect(cache.get('nfts:0xabc:0x1')).toBeNull(); + expect(cache.get('nfts:0xabc:0x89')).toBeNull(); + expect(cache.get('balance:0xabc:0x1')).toEqual(['bal']); + }); + + it('is a no-op when no keys match the prefix', () => { + cache.set('foo', 'bar', 5000); + cache.invalidateByPrefix('baz:'); + expect(cache.get('foo')).toBe('bar'); + }); + }); + + describe('size', () => { + it('reflects the number of stored (including expired) entries before eviction', () => { + cache.set('a', 1, 5000); + cache.set('b', 2, 5000); + expect(cache.size()).toBe(2); + }); + + it('decreases after delete', () => { + cache.set('a', 1, 5000); + cache.delete('a'); + expect(cache.size()).toBe(0); + }); + }); +}); + +describe('walletCacheKeys', () => { + it('builds nft key from address and chainId', () => { + expect(walletCacheKeys.nfts('0xabc', '0x1')).toBe('nfts:0xabc:0x1'); + }); + + it('builds balance key from address and chainId', () => { + expect(walletCacheKeys.balance('0xabc', '0x89')).toBe('balance:0xabc:0x89'); + }); + + it('builds defiPositions key from address', () => { + expect(walletCacheKeys.defiPositions('0xabc')).toBe('defi_positions:0xabc'); + }); + + it('builds addressPrefix key', () => { + expect(walletCacheKeys.addressPrefix('0xabc')).toBe('addr:0xabc:'); + }); +}); + +describe('CACHE_TTL', () => { + it('BALANCE is 30 seconds', () => { + expect(CACHE_TTL.BALANCE).toBe(30_000); + }); + + it('NFT is 5 minutes', () => { + expect(CACHE_TTL.NFT).toBe(300_000); + }); + + it('DEFI_POSITIONS is 60 seconds', () => { + expect(CACHE_TTL.DEFI_POSITIONS).toBe(60_000); + }); +}); + +describe('walletCache singleton', () => { + afterEach(() => { + walletCache.clear(); + }); + + it('is a shared WalletCache instance', () => { + expect(walletCache).toBeInstanceOf(WalletCache); + }); + + it('persists data across imports', () => { + walletCache.set('shared', true, 5000); + expect(walletCache.get('shared')).toBe(true); + }); +}); diff --git a/src/utils/web3/index.ts b/src/utils/web3/index.ts index 7953212..e58c676 100644 --- a/src/utils/web3/index.ts +++ b/src/utils/web3/index.ts @@ -14,6 +14,13 @@ export { export { validateWalletInteraction, type WalletInteractionResult } from './walletValidation'; +export { + WalletCache, + walletCache, + walletCacheKeys, + CACHE_TTL, +} from './walletCache'; + export { isValidEthereumAddress, isValidStarknetAddress, diff --git a/src/utils/web3/walletCache.ts b/src/utils/web3/walletCache.ts new file mode 100644 index 0000000..8347a85 --- /dev/null +++ b/src/utils/web3/walletCache.ts @@ -0,0 +1,71 @@ +interface CacheEntry { + data: T; + expiresAt: number; +} + +export class WalletCache { + private store = new Map>(); + + set(key: string, data: T, ttlMs: number): void { + this.store.set(key, { data, expiresAt: Date.now() + ttlMs }); + } + + get(key: string): T | null { + const entry = this.store.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return null; + } + return entry.data as T; + } + + has(key: string): boolean { + const entry = this.store.get(key); + if (!entry) return false; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return false; + } + return true; + } + + delete(key: string): void { + this.store.delete(key); + } + + clear(): void { + this.store.clear(); + } + + invalidateByPrefix(prefix: string): void { + for (const key of this.store.keys()) { + if (key.startsWith(prefix)) { + this.store.delete(key); + } + } + } + + size(): number { + return this.store.size; + } +} + +/** TTL constants in milliseconds */ +export const CACHE_TTL = { + BALANCE: 30_000, + NFT: 5 * 60_000, + DEFI_POSITIONS: 60_000, + CHAIN_DATA: 5 * 60_000, +} as const; + +/** Cache key builders */ +export const walletCacheKeys = { + nfts: (address: string, chainId: string) => `nfts:${address}:${chainId}`, + balance: (address: string, chainId: string) => `balance:${address}:${chainId}`, + defiPositions: (address: string) => `defi_positions:${address}`, + addressPrefix: (address: string) => `addr:${address}:`, +} as const; + +/** Shared singleton cache instance for the wallet integration */ +export const walletCache = new WalletCache();