diff --git a/.env.example b/.env.example index 445bf15..63993d9 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,6 @@ -NEXT_PUBLIC_THIRDWEB_CLIENT_ID= -THIRDWEB_SECRET_KEY= \ No newline at end of file +# Thirdweb Configuration +NEXT_PUBLIC_THIRDWEB_CLIENT_ID=your_thirdweb_client_id_here + +# Upstash Redis Configuration +UPSTASH_REDIS_REST_URL=https://your-redis-instance.upstash.io +UPSTASH_REDIS_REST_TOKEN=your_upstash_redis_token_here \ No newline at end of file diff --git a/CACHE_README.md b/CACHE_README.md new file mode 100644 index 0000000..60eac0d --- /dev/null +++ b/CACHE_README.md @@ -0,0 +1,177 @@ +# Upstash Redis Cache Implementation + +This project now includes Upstash Redis for caching token data to improve performance and reduce API calls. + +## Setup + +1. **Create an Upstash Redis Database** + - Sign up at [upstash.com](https://upstash.com) + - Create a new Redis database + - Copy your REST URL and token + +2. **Configure Environment Variables** + Add these to your `.env.local`: + ``` + UPSTASH_REDIS_REST_URL=https://your-redis-instance.upstash.io + UPSTASH_REDIS_REST_TOKEN=your_upstash_redis_token_here + ``` + +## Cache Utilities + +The cache implementation provides several utility functions in `src/app/lib/cache.ts`: + +### Main Functions + +- **`getOrSetCache(key, fetcher, options)`** - Get from cache or fetch fresh data +- **`getFromCache(key, namespace?)`** - Get data from cache only +- **`setCache(key, data, options)`** - Set data in cache +- **`invalidateCache(key, namespace?)`** - Remove a single cache entry +- **`invalidateMultiple(keys, namespace?)`** - Remove multiple cache entries +- **`invalidateByPattern(pattern, namespace?)`** - Remove entries matching pattern +- **`clearNamespace(namespace)`** - Clear all entries in a namespace +- **`cacheExists(key, namespace?)`** - Check if cache key exists +- **`getCacheTTL(key, namespace?)`** - Get remaining TTL for a key + +### Cache Key Helpers + +Pre-configured cache keys for common use cases: + +```typescript +cacheKeys.tokens(walletAddress) // For token balances +cacheKeys.tokenPrice(tokenAddress) // For token prices +cacheKeys.apiResponse(endpoint, params) // For generic API responses +``` + +## Usage Examples + +### Basic Usage in API Routes + +```typescript +import { getOrSetCache, cacheKeys } from '@/app/lib/cache'; + +// In your API route +const data = await getOrSetCache( + cacheKeys.tokens(walletAddress), + async () => { + // Fetch fresh data + const response = await fetch(...); + return response.json(); + }, + { ttl: 300 } // Cache for 5 minutes +); +``` + +### Manual Cache Invalidation + +```typescript +import { invalidateCache, cacheKeys } from '@/app/lib/cache'; + +// Invalidate specific wallet cache +await invalidateCache(cacheKeys.tokens(walletAddress)); + +// Invalidate by pattern +await invalidateByPattern('tokens:*'); // Clear all token caches +``` + +## Cache Management API + +The application includes a cache management API at `/api/cache`: + +### Check Cache Status +```bash +GET /api/cache?wallet=0x123... +``` + +Response: +```json +{ + "wallet": "0x123...", + "cacheKey": "tokens:0x123...", + "exists": true, + "ttl": 180, + "ttlReadable": "3m 0s" +} +``` + +### Invalidate Cache + +```bash +POST /api/cache +Content-Type: application/json + +{ + "action": "invalidateWallet", + "walletAddress": "0x123..." +} +``` + +Available actions: +- `invalidate` - Remove specific cache key +- `invalidatePattern` - Remove keys matching pattern +- `invalidateWallet` - Remove wallet token cache +- `clearNamespace` - Clear entire namespace + +### Examples + +```javascript +// Invalidate specific wallet +fetch('/api/cache', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'invalidateWallet', + walletAddress: '0x123...' + }) +}); + +// Clear all token caches +fetch('/api/cache', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'invalidatePattern', + pattern: 'tokens:*' + }) +}); +``` + +## Current Implementation + +The token API (`/api/tokens/[address]`) now: +1. Checks cache first for token data +2. On cache miss, fetches from Thirdweb API +3. Stores result in cache with 5-minute TTL +4. Returns cached or fresh data + +This reduces API calls and improves response times for frequently accessed wallets. + +## Performance Benefits + +- **Reduced API Calls**: Subsequent requests within 5 minutes use cached data +- **Faster Response Times**: Cache hits are near-instantaneous +- **Scalability**: Redis handles high concurrent request volumes +- **Cost Savings**: Fewer external API calls + +## Monitoring + +Cache operations are logged to console: +- Cache hits: `Cache hit for key: tokens:0x...` +- Cache misses: `Cache miss for key: tokens:0x...` +- Cache sets: `Cached data for key: tokens:0x... with TTL: 300s` +- Invalidations: `Invalidated cache for key: tokens:0x...` + +## Error Handling + +The cache system is designed to be fault-tolerant: +- If Redis is unavailable, the system falls back to fetching fresh data +- Cache errors are logged but don't break the application +- All cache operations have try-catch blocks + +## Future Enhancements + +Consider implementing: +- Cache warming for popular wallets +- Adjustable TTL based on wallet activity +- Cache statistics endpoint +- Background cache refresh +- Multi-level caching (memory + Redis) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9d4684c..73c07ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "wallet-sweep", "version": "0.1.0", "dependencies": { + "@upstash/redis": "^1.35.1", "next": "14.1.0", "react": "^18.3", "react-dom": "^18.3", @@ -77,6 +78,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@upstash/redis": { + "version": "1.35.1", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.35.1.tgz", + "integrity": "sha512-sIMuAMU9IYbE2bkgDby8KLoQKRiBMXn0moXxqLvUmQ7VUu2CvulZLtK8O0x3WQZFvvZhU5sRC2/lOVZdGfudkA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -66128,12 +66138,6 @@ "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", "license": "MIT" }, - "node_modules/thirdweb/node_modules/@walletconnect/sign-client/node_modules/@walletconnect/core/node_modules/@walletconnect/keyvaluestorage/node_modules/unstorage/node_modules/h3/node_modules/uncrypto": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", - "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", - "license": "MIT" - }, "node_modules/thirdweb/node_modules/@walletconnect/sign-client/node_modules/@walletconnect/core/node_modules/@walletconnect/keyvaluestorage/node_modules/unstorage/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -66887,12 +66891,6 @@ "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", "license": "MIT" }, - "node_modules/thirdweb/node_modules/@walletconnect/sign-client/node_modules/@walletconnect/types/node_modules/@walletconnect/keyvaluestorage/node_modules/unstorage/node_modules/h3/node_modules/uncrypto": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", - "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", - "license": "MIT" - }, "node_modules/thirdweb/node_modules/@walletconnect/sign-client/node_modules/@walletconnect/types/node_modules/@walletconnect/keyvaluestorage/node_modules/unstorage/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -67230,12 +67228,6 @@ "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", "license": "MIT" }, - "node_modules/thirdweb/node_modules/@walletconnect/sign-client/node_modules/@walletconnect/utils/node_modules/@walletconnect/keyvaluestorage/node_modules/unstorage/node_modules/h3/node_modules/uncrypto": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", - "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", - "license": "MIT" - }, "node_modules/thirdweb/node_modules/@walletconnect/sign-client/node_modules/@walletconnect/utils/node_modules/@walletconnect/keyvaluestorage/node_modules/unstorage/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -68074,12 +68066,6 @@ "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", "license": "MIT" }, - "node_modules/thirdweb/node_modules/@walletconnect/universal-provider/node_modules/@walletconnect/keyvaluestorage/node_modules/unstorage/node_modules/h3/node_modules/uncrypto": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", - "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", - "license": "MIT" - }, "node_modules/thirdweb/node_modules/@walletconnect/universal-provider/node_modules/@walletconnect/keyvaluestorage/node_modules/unstorage/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -71551,140 +71537,11 @@ "node": ">=14.17" } }, - "node_modules/@next/swc-darwin-arm64": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.0.tgz", - "integrity": "sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.0.tgz", - "integrity": "sha512-1jgudN5haWxiAl3O1ljUS2GfupPmcftu2RYJqZiMJmmbBT5M1XDffjUtRUzP4W3cBHsrvkfOFdQ71hAreNQP6g==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.0.tgz", - "integrity": "sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.0.tgz", - "integrity": "sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.0.tgz", - "integrity": "sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.0.tgz", - "integrity": "sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.0.tgz", - "integrity": "sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.0.tgz", - "integrity": "sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.0.tgz", - "integrity": "sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" } } } diff --git a/package.json b/package.json index c07cd70..f625ef4 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@upstash/redis": "^1.35.1", "next": "14.1.0", "react": "^18.3", "react-dom": "^18.3", diff --git a/src/app/api/cache/route.ts b/src/app/api/cache/route.ts new file mode 100644 index 0000000..0949935 --- /dev/null +++ b/src/app/api/cache/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { invalidateCache, invalidateByPattern, clearNamespace, cacheKeys } from '@/app/lib/cache'; + +// POST endpoint to invalidate cache +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { action, key, pattern, namespace, walletAddress } = body; + + switch (action) { + case 'invalidate': + // Invalidate a specific cache key + if (!key) { + return NextResponse.json( + { error: 'Key is required for invalidate action' }, + { status: 400 } + ); + } + const success = await invalidateCache(key, namespace); + return NextResponse.json({ + success, + message: success ? `Cache key ${key} invalidated` : 'Failed to invalidate cache key', + }); + + case 'invalidatePattern': + // Invalidate cache keys matching a pattern + if (!pattern) { + return NextResponse.json( + { error: 'Pattern is required for invalidatePattern action' }, + { status: 400 } + ); + } + const deletedCount = await invalidateByPattern(pattern, namespace); + return NextResponse.json({ + success: deletedCount > 0, + deletedCount, + message: `Invalidated ${deletedCount} cache entries matching pattern ${pattern}`, + }); + + case 'invalidateWallet': + // Invalidate cache for a specific wallet address + if (!walletAddress) { + return NextResponse.json( + { error: 'Wallet address is required for invalidateWallet action' }, + { status: 400 } + ); + } + const walletKey = cacheKeys.tokens(walletAddress); + const walletSuccess = await invalidateCache(walletKey); + return NextResponse.json({ + success: walletSuccess, + message: walletSuccess + ? `Cache for wallet ${walletAddress} invalidated` + : 'Failed to invalidate wallet cache', + }); + + case 'clearNamespace': + // Clear all cache entries in a namespace + if (!namespace) { + return NextResponse.json( + { error: 'Namespace is required for clearNamespace action' }, + { status: 400 } + ); + } + const clearedCount = await clearNamespace(namespace); + return NextResponse.json({ + success: clearedCount > 0, + clearedCount, + message: `Cleared ${clearedCount} cache entries in namespace ${namespace}`, + }); + + default: + return NextResponse.json( + { error: 'Invalid action. Supported actions: invalidate, invalidatePattern, invalidateWallet, clearNamespace' }, + { status: 400 } + ); + } + } catch (error) { + console.error('Cache API error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +// GET endpoint to check cache status (optional) +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const walletAddress = searchParams.get('wallet'); + + if (!walletAddress) { + return NextResponse.json( + { error: 'Wallet address is required' }, + { status: 400 } + ); + } + + try { + const { cacheExists, getCacheTTL } = await import('@/app/lib/cache'); + const key = cacheKeys.tokens(walletAddress); + + const exists = await cacheExists(key); + const ttl = exists ? await getCacheTTL(key) : null; + + return NextResponse.json({ + wallet: walletAddress, + cacheKey: key, + exists, + ttl, + ttlReadable: ttl && ttl > 0 ? `${Math.floor(ttl / 60)}m ${ttl % 60}s` : null, + }); + } catch (error) { + console.error('Cache status check error:', error); + return NextResponse.json( + { error: 'Failed to check cache status' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/tokens/[address]/route.ts b/src/app/api/tokens/[address]/route.ts index 76d7b8a..dd92957 100644 --- a/src/app/api/tokens/[address]/route.ts +++ b/src/app/api/tokens/[address]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAddress } from 'thirdweb/utils'; +import { getOrSetCache, cacheKeys, invalidateCache } from '@/app/lib/cache'; const THIRDWEB_API_URL = 'https://api.thirdweb.com'; const BASE_CHAIN_ID = 8453; // Base mainnet @@ -104,134 +105,140 @@ export async function GET( ); } - const processedTokens: ProcessedToken[] = []; - - // Fetch both ETH balance and ERC20 tokens in parallel - const [ethResponse, tokensResponse] = await Promise.all([ - // Fetch ETH balance - fetch(`${THIRDWEB_API_URL}/v1/wallets/${address}/balance?chainId=${BASE_CHAIN_ID}`, { - method: 'GET', - headers: { - 'x-client-id': clientId, - 'Content-Type': 'application/json', - }, - }), - // Fetch ERC20 tokens - fetch(`${THIRDWEB_API_URL}/v1/wallets/${address}/tokens?chainId=${BASE_CHAIN_ID}&limit=50`, { - method: 'GET', - headers: { - 'x-client-id': clientId, - 'Content-Type': 'application/json', - }, - }) - ]); - - // Process ETH balance - if (ethResponse.ok) { - try { - const ethData = await ethResponse.json(); - const ethBalance = parseFloat(ethData.result?.displayValue || '0'); - - if (ethBalance > 0.0001) { - // Get ETH price (you could also fetch this from a price API) - const ethPriceUsd = 3000; // Mock price - in production, fetch from CoinGecko/CoinMarketCap - - processedTokens.push({ - address: "ETH", - symbol: "ETH", - name: "Ethereum", - balance: ethData.result?.value || "0", - decimals: 18, - logo: "https://assets.coingecko.com/coins/images/279/thumb/ethereum.png", - value: ethBalance * ethPriceUsd, - chainId: BASE_CHAIN_ID, - priceUsd: ethPriceUsd, - balanceFormatted: ethBalance - }); - } - } catch (ethError) { - console.error('Error processing ETH balance:', ethError); - } - } + // Generate cache key for this wallet + const cacheKey = cacheKeys.tokens(address); + + // Use cache with a TTL of 5 minutes (300 seconds) + const response = await getOrSetCache( + cacheKey, + async () => { + const processedTokens: ProcessedToken[] = []; + + // Fetch both ETH balance and ERC20 tokens in parallel + const [ethResponse, tokensResponse] = await Promise.all([ + // Fetch ETH balance + fetch(`${THIRDWEB_API_URL}/v1/wallets/${address}/balance?chainId=${BASE_CHAIN_ID}`, { + method: 'GET', + headers: { + 'x-client-id': clientId, + 'Content-Type': 'application/json', + }, + }), + // Fetch ERC20 tokens + fetch(`${THIRDWEB_API_URL}/v1/wallets/${address}/tokens?chainId=${BASE_CHAIN_ID}&limit=50`, { + method: 'GET', + headers: { + 'x-client-id': clientId, + 'Content-Type': 'application/json', + }, + }) + ]); - // Process ERC20 tokens - if (tokensResponse.ok) { - try { - const tokensData = await tokensResponse.json(); - - if (tokensData.result?.tokens) { - // Process tokens in batches to avoid overwhelming the server with image validation requests - const tokenPromises = tokensData.result.tokens.map(async (token: any) => { - const balanceFormatted = parseFloat(token.balance) / Math.pow(10, token.decimals || 18); - const priceUsd = token.price_data?.price_usd || 0; + // Process ETH balance + if (ethResponse.ok) { + try { + const ethData = await ethResponse.json(); + const ethBalance = parseFloat(ethData.result?.displayValue || '0'); - // Only include tokens with meaningful balance - if (balanceFormatted > 0.0001) { - const logo = await getTokenIcon(token, token.token_address, token.symbol || 'UNKNOWN'); + if (ethBalance > 0.0001) { + // Get ETH price (you could also fetch this from a price API) + const ethPriceUsd = 3000; // Mock price - in production, fetch from CoinGecko/CoinMarketCap - return { - address: token.token_address, - symbol: token.symbol || 'UNKNOWN', - name: token.name || 'Unknown Token', - balance: token.balance, - decimals: token.decimals || 18, - logo, - value: balanceFormatted * priceUsd, + processedTokens.push({ + address: "ETH", + symbol: "ETH", + name: "Ethereum", + balance: ethData.result?.value || "0", + decimals: 18, + logo: "https://assets.coingecko.com/coins/images/279/thumb/ethereum.png", + value: ethBalance * ethPriceUsd, chainId: BASE_CHAIN_ID, - priceUsd: priceUsd, - balanceFormatted: balanceFormatted - }; + priceUsd: ethPriceUsd, + balanceFormatted: ethBalance + }); } - return null; - }); - - // Wait for all token processing to complete - const results = await Promise.all(tokenPromises); + } catch (ethError) { + console.error('Error processing ETH balance:', ethError); + } + } + + // Process ERC20 tokens + if (tokensResponse.ok) { + try { + const tokensData = await tokensResponse.json(); + + if (tokensData.result?.tokens) { + // Process tokens in batches to avoid overwhelming the server with image validation requests + const tokenPromises = tokensData.result.tokens.map(async (token: any) => { + const balanceFormatted = parseFloat(token.balance) / Math.pow(10, token.decimals || 18); + const priceUsd = token.price_data?.price_usd || 0; + + // Only include tokens with meaningful balance + if (balanceFormatted > 0.0001) { + const logo = await getTokenIcon(token, token.token_address, token.symbol || 'UNKNOWN'); + + return { + address: token.token_address, + symbol: token.symbol || 'UNKNOWN', + name: token.name || 'Unknown Token', + balance: token.balance, + decimals: token.decimals || 18, + logo, + value: balanceFormatted * priceUsd, + chainId: BASE_CHAIN_ID, + priceUsd: priceUsd, + balanceFormatted: balanceFormatted + }; + } + return null; + }); - // Filter out null results and add to processedTokens - results.forEach(result => { - if (result) { - processedTokens.push(result); + // Wait for all token processing to complete + const results = await Promise.all(tokenPromises); + + // Filter out null results and add to processedTokens + results.forEach(result => { + if (result) { + processedTokens.push(result); + } + }); } - }); + } catch (tokensError) { + console.error('Error processing tokens:', tokensError); + } } - } catch (tokensError) { - console.error('Error processing tokens:', tokensError); - } - } - // Handle API errors - if (!ethResponse.ok && !tokensResponse.ok) { - const ethError = await ethResponse.text(); - const tokensError = await tokensResponse.text(); - console.error('Both API calls failed:', { ethError, tokensError }); - - if (ethResponse.status === 401 || tokensResponse.status === 401) { - return NextResponse.json( - { error: 'Authentication failed. Please check your client ID.' }, - { status: 401 } - ); - } - - return NextResponse.json( - { error: 'Failed to fetch wallet data from Thirdweb API' }, - { status: 500 } - ); - } + // Handle API errors + if (!ethResponse.ok && !tokensResponse.ok) { + const ethError = await ethResponse.text(); + const tokensError = await tokensResponse.text(); + console.error('Both API calls failed:', { ethError, tokensError }); + + if (ethResponse.status === 401 || tokensResponse.status === 401) { + throw new Error('Authentication failed. Please check your client ID.'); + } + + throw new Error('Failed to fetch wallet data from Thirdweb API'); + } - // Sort tokens by USD value (highest first) - processedTokens.sort((a, b) => b.value - a.value); + // Sort tokens by USD value (highest first) + processedTokens.sort((a, b) => b.value - a.value); - console.log(`Successfully processed ${processedTokens.length} tokens for ${address}`); + console.log(`Successfully processed ${processedTokens.length} tokens for ${address}`); - return NextResponse.json({ - success: true, - address, - chainId: BASE_CHAIN_ID, - tokens: processedTokens, - totalUsdValue: processedTokens.reduce((sum, token) => sum + token.value, 0), - timestamp: new Date().toISOString(), - }); + return { + success: true, + address, + chainId: BASE_CHAIN_ID, + tokens: processedTokens, + totalUsdValue: processedTokens.reduce((sum, token) => sum + token.value, 0), + timestamp: new Date().toISOString(), + }; + }, + { ttl: 300 } // Cache for 5 minutes + ); + + return NextResponse.json(response); } catch (error) { console.error('API route error:', error); diff --git a/src/app/hooks/useCache.ts b/src/app/hooks/useCache.ts new file mode 100644 index 0000000..f249b6b --- /dev/null +++ b/src/app/hooks/useCache.ts @@ -0,0 +1,105 @@ +import { useState, useCallback } from 'react'; + +interface CacheInvalidationResult { + success: boolean; + message?: string; + error?: string; +} + +export const useCache = () => { + const [invalidating, setInvalidating] = useState(false); + + const invalidateWalletCache = useCallback(async (walletAddress: string): Promise => { + setInvalidating(true); + + try { + const response = await fetch('/api/cache', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'invalidateWallet', + walletAddress, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to invalidate cache'); + } + + return { + success: data.success, + message: data.message, + }; + } catch (error) { + console.error('Cache invalidation error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to invalidate cache', + }; + } finally { + setInvalidating(false); + } + }, []); + + const checkCacheStatus = useCallback(async (walletAddress: string) => { + try { + const response = await fetch(`/api/cache?wallet=${walletAddress}`); + + if (!response.ok) { + throw new Error('Failed to check cache status'); + } + + return await response.json(); + } catch (error) { + console.error('Cache status check error:', error); + return null; + } + }, []); + + const invalidatePattern = useCallback(async (pattern: string): Promise => { + setInvalidating(true); + + try { + const response = await fetch('/api/cache', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'invalidatePattern', + pattern, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to invalidate cache pattern'); + } + + return { + success: data.success, + message: data.message, + }; + } catch (error) { + console.error('Cache pattern invalidation error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to invalidate cache pattern', + }; + } finally { + setInvalidating(false); + } + }, []); + + return { + invalidateWalletCache, + checkCacheStatus, + invalidatePattern, + invalidating, + }; +}; \ No newline at end of file diff --git a/src/app/lib/cache.ts b/src/app/lib/cache.ts new file mode 100644 index 0000000..37a20fb --- /dev/null +++ b/src/app/lib/cache.ts @@ -0,0 +1,244 @@ +import redis from './redis'; + +export interface CacheOptions { + ttl?: number; // Time to live in seconds + namespace?: string; // Optional namespace for cache keys +} + +/** + * Generate a cache key with optional namespace + */ +export function generateCacheKey(key: string, namespace?: string): string { + return namespace ? `${namespace}:${key}` : key; +} + +/** + * Get data from cache or fetch fresh data if cache miss + * @param key Cache key + * @param fetcher Function to fetch fresh data + * @param options Cache options + */ +export async function getOrSetCache( + key: string, + fetcher: () => Promise, + options: CacheOptions = {} +): Promise { + const { ttl = 3600, namespace } = options; // Default TTL: 1 hour + const cacheKey = generateCacheKey(key, namespace); + + try { + // Try to get from cache + const cached = await redis.get(cacheKey); + if (cached) { + console.log(`Cache hit for key: ${cacheKey}`); + return cached as T; + } + + console.log(`Cache miss for key: ${cacheKey}`); + // Fetch fresh data + const freshData = await fetcher(); + + // Store in cache + if (ttl > 0) { + await redis.set(cacheKey, freshData, { ex: ttl }); + console.log(`Cached data for key: ${cacheKey} with TTL: ${ttl}s`); + } else { + await redis.set(cacheKey, freshData); + console.log(`Cached data for key: ${cacheKey} without expiration`); + } + + return freshData; + } catch (error) { + console.error(`Cache error for key ${cacheKey}:`, error); + // If Redis fails, fallback to fetcher + return fetcher(); + } +} + +/** + * Get data from cache + */ +export async function getFromCache( + key: string, + namespace?: string +): Promise { + const cacheKey = generateCacheKey(key, namespace); + + try { + const cached = await redis.get(cacheKey); + return cached as T; + } catch (error) { + console.error(`Error getting from cache for key ${cacheKey}:`, error); + return null; + } +} + +/** + * Set data in cache + */ +export async function setCache( + key: string, + data: T, + options: CacheOptions = {} +): Promise { + const { ttl = 3600, namespace } = options; + const cacheKey = generateCacheKey(key, namespace); + + try { + if (ttl > 0) { + await redis.set(cacheKey, data, { ex: ttl }); + } else { + await redis.set(cacheKey, data); + } + console.log(`Set cache for key: ${cacheKey}`); + return true; + } catch (error) { + console.error(`Error setting cache for key ${cacheKey}:`, error); + return false; + } +} + +/** + * Invalidate a single cache entry + */ +export async function invalidateCache( + key: string, + namespace?: string +): Promise { + const cacheKey = generateCacheKey(key, namespace); + + try { + const result = await redis.del(cacheKey); + console.log(`Invalidated cache for key: ${cacheKey}`); + return result === 1; + } catch (error) { + console.error(`Error invalidating cache for key ${cacheKey}:`, error); + return false; + } +} + +/** + * Invalidate multiple cache entries + */ +export async function invalidateMultiple( + keys: string[], + namespace?: string +): Promise { + if (keys.length === 0) return 0; + + const cacheKeys = keys.map(key => generateCacheKey(key, namespace)); + + try { + const pipeline = redis.pipeline(); + cacheKeys.forEach(key => pipeline.del(key)); + const results = await pipeline.exec(); + + const deletedCount = results.reduce((acc, result) => { + return acc + (result === 1 ? 1 : 0); + }, 0); + + console.log(`Invalidated ${deletedCount} cache entries`); + return deletedCount; + } catch (error) { + console.error('Error invalidating multiple cache entries:', error); + return 0; + } +} + +/** + * Invalidate cache entries by pattern + * Note: This requires scanning keys which can be expensive on large datasets + */ +export async function invalidateByPattern( + pattern: string, + namespace?: string +): Promise { + const searchPattern = namespace ? `${namespace}:${pattern}` : pattern; + let deletedCount = 0; + + try { + // Scan for keys matching the pattern + let cursor = 0; + do { + const result = await redis.scan(cursor, { + match: searchPattern, + count: 100 + }); + + cursor = result[0]; + const keys = result[1]; + + if (keys.length > 0) { + const pipeline = redis.pipeline(); + keys.forEach(key => pipeline.del(key)); + const results = await pipeline.exec(); + + deletedCount += results.reduce((acc, result) => { + return acc + (result === 1 ? 1 : 0); + }, 0); + } + } while (cursor !== 0); + + console.log(`Invalidated ${deletedCount} cache entries matching pattern: ${searchPattern}`); + return deletedCount; + } catch (error) { + console.error(`Error invalidating cache by pattern ${searchPattern}:`, error); + return 0; + } +} + +/** + * Clear all cache entries in a namespace + */ +export async function clearNamespace(namespace: string): Promise { + return invalidateByPattern('*', namespace); +} + +/** + * Check if a cache key exists + */ +export async function cacheExists( + key: string, + namespace?: string +): Promise { + const cacheKey = generateCacheKey(key, namespace); + + try { + const exists = await redis.exists(cacheKey); + return exists === 1; + } catch (error) { + console.error(`Error checking cache existence for key ${cacheKey}:`, error); + return false; + } +} + +/** + * Get remaining TTL for a cache key + */ +export async function getCacheTTL( + key: string, + namespace?: string +): Promise { + const cacheKey = generateCacheKey(key, namespace); + + try { + const ttl = await redis.ttl(cacheKey); + return ttl; + } catch (error) { + console.error(`Error getting TTL for key ${cacheKey}:`, error); + return null; + } +} + +// Cache key helpers for common use cases +export const cacheKeys = { + // Token-related cache keys + tokens: (walletAddress: string) => `tokens:${walletAddress.toLowerCase()}`, + tokenPrice: (tokenAddress: string) => `price:${tokenAddress.toLowerCase()}`, + + // Generate cache key for API responses + apiResponse: (endpoint: string, params?: Record) => { + const paramStr = params ? JSON.stringify(params, Object.keys(params).sort()) : ''; + return `api:${endpoint}:${paramStr}`; + } +}; \ No newline at end of file diff --git a/src/app/lib/redis.ts b/src/app/lib/redis.ts new file mode 100644 index 0000000..d8def29 --- /dev/null +++ b/src/app/lib/redis.ts @@ -0,0 +1,9 @@ +import { Redis } from '@upstash/redis'; + +// Initialize Redis client +const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, +}); + +export default redis; \ No newline at end of file