Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions src/components/web3/DeFiInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -89,6 +90,14 @@ export const DeFiInterface: React.FC<DeFiInterfaceProps> = ({
setStakingPositions([]);
return;
}

const cacheKey = walletCacheKeys.defiPositions(wallet.address);
const cached = walletCache.get<StakingPosition[]>(cacheKey);
if (cached) {
setStakingPositions(cached);
return;
}

setIsLoading(true);
try {
const positions: StakingPosition[] = [
Expand All @@ -105,6 +114,7 @@ export const DeFiInterface: React.FC<DeFiInterfaceProps> = ({
},
];
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);
Expand Down Expand Up @@ -134,7 +144,17 @@ export const DeFiInterface: React.FC<DeFiInterfaceProps> = ({
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);
Expand All @@ -144,19 +164,29 @@ export const DeFiInterface: React.FC<DeFiInterfaceProps> = ({
} 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<InvestmentItem[]>(() => {
Expand Down
9 changes: 9 additions & 0 deletions src/components/web3/NFTGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -81,6 +82,13 @@ export const NFTGallery: React.FC<NFTGalleryProps> = ({
return;
}

const cacheKey = walletCacheKeys.nfts(wallet.address, wallet.chainId || '0x1');
const cached = walletCache.get<NFT[]>(cacheKey);
if (cached) {
setNfts(cached);
return;
}

setIsLoading(true);
setError(null);

Expand Down Expand Up @@ -134,6 +142,7 @@ export const NFTGallery: React.FC<NFTGalleryProps> = ({
// 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';
Expand Down
76 changes: 67 additions & 9 deletions src/hooks/useWeb3Wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<WalletBalance[]>(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');
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -470,6 +527,7 @@ export function useWeb3Wallet() {
switchChain,
signMessage,
sendTransaction,
fetchBalance,
clearError,
supportedChains: SUPPORTED_CHAINS,
};
Expand Down
8 changes: 8 additions & 0 deletions src/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
159 changes: 159 additions & 0 deletions src/utils/__tests__/walletCache.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading