From 341352d568a53661e10b71d01f34cf4424b76376 Mon Sep 17 00:00:00 2001 From: Sylvester Menawar Date: Mon, 1 Jun 2026 05:49:23 +0100 Subject: [PATCH] feat(services): replace hardcoded prices with CoinGecko API Adds priceService.ts which fetches real token USD prices from the CoinGecko free-tier simple/price endpoint. Implements a 5-minute in-memory cache (with optional AsyncStorage persistence) to reduce API calls and provide stale-data fallback when the API is unavailable. Adds useTokenPrices hook with 60-second periodic refresh, pull-to- refresh support, and graceful error handling for network failures, timeouts, and rate limiting. Removes all hardcoded mock price values from components and screens. Closes #70 --- src/hooks/useTokenPrices.test.ts | 252 +++++++++++++++++++ src/hooks/useTokenPrices.ts | 230 +++++++++++++++++ src/screens/WalletConnectScreen.tsx | 98 ++++++-- src/screens/WalletConnectV2Screen.tsx | 94 ++++++- src/services/priceService.test.ts | 238 ++++++++++++++++++ src/services/priceService.ts | 340 ++++++++++++++++++++++++++ 6 files changed, 1226 insertions(+), 26 deletions(-) create mode 100644 src/hooks/useTokenPrices.test.ts create mode 100644 src/hooks/useTokenPrices.ts create mode 100644 src/services/priceService.test.ts create mode 100644 src/services/priceService.ts diff --git a/src/hooks/useTokenPrices.test.ts b/src/hooks/useTokenPrices.test.ts new file mode 100644 index 00000000..a6d41dea --- /dev/null +++ b/src/hooks/useTokenPrices.test.ts @@ -0,0 +1,252 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { clearPriceCache, fetchTokenPrices, getTokenPrice } from '../services/priceService'; +import { useTokenPrices } from './useTokenPrices'; + +const mockFetchTokenPrices = fetchTokenPrices as jest.MockedFunction; +const mockGetTokenPrice = getTokenPrice as jest.MockedFunction; +const mockClearPriceCache = clearPriceCache as jest.MockedFunction; + +jest.mock('../services/priceService', () => ({ + TICKER_TO_COINGECKO_ID: { + BTC: 'bitcoin', + ETH: 'ethereum', + XLM: 'stellar', + SOL: 'solana', + USDC: 'usd-coin', + BNB: 'binancecoin', + MATIC: 'polygon-ecosystem-token', + ARB: 'arbitrum', + DAI: 'dai', + WBTC: 'wrapped-bitcoin', + }, + fetchTokenPrices: jest.fn(), + getTokenPrice: jest.fn(), + clearPriceCache: jest.fn(), +})); + +function createPrice(id: string, usd: number, usd24hChange: number, available = true) { + return { + id, + usd, + usd24hChange, + fetchedAt: 1_000, + available, + }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockClearPriceCache.mockClear(); + mockGetTokenPrice.mockReturnValue(null); +}); + +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +describe('useTokenPrices', () => { + it('returns isLoading true on initial mount before fetch resolves', async () => { + let resolveFetch!: (value: Awaited>) => void; + mockFetchTokenPrices.mockImplementationOnce( + () => + new Promise>>((resolve) => { + resolveFetch = resolve; + }) + ); + + const { result } = renderHook(() => useTokenPrices({ tokenIds: ['BTC'] })); + + expect(result.current.isLoading).toBe(true); + expect(result.current.prices).toEqual({}); + + await act(async () => { + resolveFetch({ + prices: { + bitcoin: createPrice('bitcoin', 67000, 2.35), + }, + fromCache: false, + error: null, + }); + await flushPromises(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.prices.bitcoin.usd).toBe(67000); + expect(result.current.error).toBeNull(); + }); + + it('returns prices and isLoading false after fetch resolves', async () => { + mockFetchTokenPrices.mockResolvedValueOnce({ + prices: { + bitcoin: createPrice('bitcoin', 67000, 2.35), + }, + fromCache: false, + error: null, + }); + + const { result } = renderHook(() => useTokenPrices({ tokenIds: ['BTC'] })); + + await act(async () => { + await flushPromises(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.prices.bitcoin).toMatchObject({ + id: 'bitcoin', + usd: 67000, + usd24hChange: 2.35, + available: true, + }); + }); + + it('sets up an interval and re-fetches after refreshIntervalMs', async () => { + let resolveRefresh!: (value: Awaited>) => void; + mockFetchTokenPrices + .mockResolvedValueOnce({ + prices: { + bitcoin: createPrice('bitcoin', 67000, 2.35), + }, + fromCache: false, + error: null, + }) + .mockImplementationOnce( + () => + new Promise>>((resolve) => { + resolveRefresh = resolve; + }) + ); + + const { result } = renderHook(() => + useTokenPrices({ tokenIds: ['BTC'], refreshIntervalMs: 60_000 }) + ); + + await act(async () => { + await flushPromises(); + }); + + expect(mockFetchTokenPrices).toHaveBeenCalledTimes(1); + expect(result.current.prices.bitcoin.usd).toBe(67000); + + act(() => { + jest.advanceTimersByTime(60_000); + }); + + expect(mockFetchTokenPrices).toHaveBeenCalledTimes(2); + + await act(async () => { + resolveRefresh({ + prices: { + bitcoin: createPrice('bitcoin', 68000, 1.1), + }, + fromCache: false, + error: null, + }); + await flushPromises(); + }); + + expect(result.current.prices.bitcoin.usd).toBe(68000); + }); + + it('cleans up interval on unmount', async () => { + mockFetchTokenPrices.mockResolvedValueOnce({ + prices: { + bitcoin: createPrice('bitcoin', 67000, 2.35), + }, + fromCache: false, + error: null, + }); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + const { unmount } = renderHook(() => useTokenPrices({ tokenIds: ['BTC'] })); + + await act(async () => { + await flushPromises(); + }); + + unmount(); + + expect(clearIntervalSpy).toHaveBeenCalled(); + clearIntervalSpy.mockRestore(); + }); + + it('sets isRefreshing true during manual refresh and false after', async () => { + let resolveRefresh!: (value: Awaited>) => void; + mockFetchTokenPrices + .mockResolvedValueOnce({ + prices: { + bitcoin: createPrice('bitcoin', 67000, 2.35), + }, + fromCache: false, + error: null, + }) + .mockImplementationOnce( + () => + new Promise>>((resolve) => { + resolveRefresh = resolve; + }) + ); + + const { result } = renderHook(() => useTokenPrices({ tokenIds: ['BTC'] })); + + await act(async () => { + await flushPromises(); + }); + + let refreshPromise!: Promise; + act(() => { + refreshPromise = result.current.refresh(); + }); + + expect(result.current.isRefreshing).toBe(true); + + await act(async () => { + resolveRefresh({ + prices: { + bitcoin: createPrice('bitcoin', 68000, 1.1), + }, + fromCache: false, + error: null, + }); + await refreshPromise; + await flushPromises(); + }); + + expect(result.current.isRefreshing).toBe(false); + expect(result.current.prices.bitcoin.usd).toBe(68000); + }); + + it('returns an error when fetch fails', async () => { + mockFetchTokenPrices.mockResolvedValueOnce({ + prices: { + bitcoin: createPrice('bitcoin', 0, 0, false), + }, + fromCache: true, + error: 'Unable to load prices', + }); + + const { result } = renderHook(() => useTokenPrices({ tokenIds: ['BTC'] })); + + await act(async () => { + await flushPromises(); + }); + + expect(result.current.error).toBe('Unable to load prices'); + expect(result.current.prices.bitcoin.usd).toBe(0); + expect(result.current.prices.bitcoin.available).toBe(false); + }); + + it('does not fetch when enabled is false', () => { + const { result } = renderHook(() => useTokenPrices({ tokenIds: ['BTC'], enabled: false })); + + expect(mockFetchTokenPrices).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.prices).toEqual({}); + }); +}); diff --git a/src/hooks/useTokenPrices.ts b/src/hooks/useTokenPrices.ts new file mode 100644 index 00000000..520397c2 --- /dev/null +++ b/src/hooks/useTokenPrices.ts @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + fetchTokenPrices, + getTokenPrice, + TICKER_TO_COINGECKO_ID, + type TokenPrice, +} from '../services/priceService'; + +interface UseTokenPricesOptions { + tokenIds: string[]; + refreshIntervalMs?: number; + enabled?: boolean; +} + +interface UseTokenPricesResult { + prices: Record; + isLoading: boolean; + isRefreshing: boolean; + error: string | null; + fromCache: boolean; + refresh: () => Promise; + lastUpdated: number | null; +} + +const DEFAULT_REFRESH_INTERVAL_MS = 60_000; + +function normalizeTokenId(tokenId: string): string { + const trimmed = tokenId.trim(); + if (!trimmed) { + return ''; + } + + const mapped = TICKER_TO_COINGECKO_ID[trimmed.toUpperCase()]; + return mapped ?? trimmed.toLowerCase(); +} + +function normalizeTokenIds(tokenIds: string[]): string[] { + const seen = new Set(); + const normalized: string[] = []; + + for (const tokenId of tokenIds) { + const resolved = normalizeTokenId(tokenId); + if (!resolved || seen.has(resolved)) { + continue; + } + + seen.add(resolved); + normalized.push(resolved); + } + + return normalized; +} + +function readCachedPrices(tokenIds: string[]): Record { + const prices: Record = {}; + + for (const tokenId of tokenIds) { + const cached = getTokenPrice(tokenId); + if (cached) { + prices[tokenId] = cached; + } + } + + return prices; +} + +function getLatestFetchedAt(prices: Record): number | null { + const timestamps = Object.values(prices).map((price) => price.fetchedAt); + if (timestamps.length === 0) { + return null; + } + + return Math.max(...timestamps); +} + +export function useTokenPrices(options: UseTokenPricesOptions): UseTokenPricesResult { + const { tokenIds, refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, enabled = true } = options; + + const tokenIdsKey = tokenIds.join('|'); + const normalizedTokenIds = useMemo(() => normalizeTokenIds(tokenIds), [tokenIdsKey]); + const normalizedKey = normalizedTokenIds.join('|'); + const effectiveRefreshIntervalMs = Math.max(refreshIntervalMs, DEFAULT_REFRESH_INTERVAL_MS); + + const [prices, setPrices] = useState>(() => + readCachedPrices(normalizedTokenIds) + ); + const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const [fromCache, setFromCache] = useState(true); + const [lastUpdated, setLastUpdated] = useState(() => + getLatestFetchedAt(readCachedPrices(normalizedTokenIds)) + ); + + const mountedRef = useRef(true); + const inFlightRef = useRef | null>(null); + const pricesRef = useRef>(prices); + const normalizedTokenIdsRef = useRef(normalizedTokenIds); + const requestVersionRef = useRef(0); + + useEffect(() => { + pricesRef.current = prices; + }, [prices]); + + useEffect(() => { + normalizedTokenIdsRef.current = normalizedTokenIds; + }, [normalizedKey]); + + const runFetch = useCallback( + async (manual = false): Promise => { + const tokens = normalizedTokenIdsRef.current; + const requestVersion = requestVersionRef.current; + + if (!enabled || tokens.length === 0) { + return; + } + + if (inFlightRef.current) { + if (manual) { + setIsRefreshing(true); + inFlightRef.current.finally(() => { + if (mountedRef.current) { + setIsRefreshing(false); + } + }); + } + return inFlightRef.current; + } + + const shouldShowLoading = Object.keys(pricesRef.current).length === 0; + if (manual) { + setIsRefreshing(true); + } else if (shouldShowLoading) { + setIsLoading(true); + } + + const fetchPromise = (async () => { + try { + const result = await fetchTokenPrices(tokens); + if (!mountedRef.current || requestVersion !== requestVersionRef.current) { + return; + } + + pricesRef.current = result.prices; + setPrices(result.prices); + setFromCache(result.fromCache); + setError(result.error); + setLastUpdated(getLatestFetchedAt(result.prices)); + } catch (fetchError) { + if (!mountedRef.current) { + return; + } + + const message = + fetchError instanceof Error ? fetchError.message : 'Unable to load prices'; + setError(message); + } finally { + if (!mountedRef.current || requestVersion !== requestVersionRef.current) { + return; + } + + setIsLoading(false); + if (manual) { + setIsRefreshing(false); + } + + inFlightRef.current = null; + } + })(); + + inFlightRef.current = fetchPromise; + return fetchPromise; + }, + [enabled, normalizedKey] + ); + + useEffect(() => { + mountedRef.current = true; + requestVersionRef.current += 1; + inFlightRef.current = null; + + const cachedPrices = readCachedPrices(normalizedTokenIds); + pricesRef.current = cachedPrices; + normalizedTokenIdsRef.current = normalizedTokenIds; + setPrices(cachedPrices); + setFromCache(true); + setError(null); + setLastUpdated(getLatestFetchedAt(cachedPrices)); + + if (!enabled || normalizedTokenIds.length === 0) { + setIsLoading(false); + setIsRefreshing(false); + return () => { + mountedRef.current = false; + }; + } + + const hasAnyCachedData = normalizedTokenIds.some((tokenId) => getTokenPrice(tokenId) !== null); + setIsLoading(!hasAnyCachedData); + + void runFetch(false); + + const intervalId = setInterval(() => { + void runFetch(false); + }, effectiveRefreshIntervalMs); + + return () => { + mountedRef.current = false; + clearInterval(intervalId); + }; + }, [enabled, effectiveRefreshIntervalMs, normalizedKey, runFetch, normalizedTokenIds]); + + const refresh = useCallback(async (): Promise => { + if (!enabled || normalizedTokenIdsRef.current.length === 0) { + return; + } + + await runFetch(true); + }, [enabled, normalizedKey, runFetch]); + + return { + prices, + isLoading, + isRefreshing, + error, + fromCache, + refresh, + lastUpdated, + }; +} diff --git a/src/screens/WalletConnectScreen.tsx b/src/screens/WalletConnectScreen.tsx index a3426314..026b7d51 100644 --- a/src/screens/WalletConnectScreen.tsx +++ b/src/screens/WalletConnectScreen.tsx @@ -5,6 +5,7 @@ import { StyleSheet, SafeAreaView, ScrollView, + RefreshControl, TouchableOpacity, Alert, ActivityIndicator, @@ -17,6 +18,8 @@ import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; import { useAppKit, useAppKitAccount, useAppKitProvider } from '@reown/appkit-ethers-react-native'; import walletServiceManager, { WalletConnection, TokenBalance } from '../services/walletService'; +import { TICKER_TO_COINGECKO_ID } from '../services/priceService'; +import { useTokenPrices } from '../hooks/useTokenPrices'; import { useWalletStore } from '../store'; import { RootStackParamList } from '../navigation/types'; @@ -33,6 +36,18 @@ const WalletConnectScreen: React.FC = () => { const [connection, setConnection] = useState(null); const [tokenBalances, setTokenBalances] = useState([]); const [isLoadingBalances, setIsLoadingBalances] = useState(false); + const tokenPriceIds = tokenBalances.map((token) => token.symbol); + const { + prices, + isLoading: isPriceLoading, + isRefreshing, + error: priceError, + refresh, + lastUpdated, + } = useTokenPrices({ + tokenIds: tokenPriceIds, + enabled: tokenPriceIds.length > 0, + }); useEffect(() => { initializeWalletService(); @@ -114,8 +129,8 @@ const WalletConnectScreen: React.FC = () => { } }; - const handleRefreshBalances = () => { - loadTokenBalances(); + const handleRefreshBalances = async () => { + await Promise.all([loadTokenBalances(), refresh()]); }; // Handle Copy Address @@ -185,19 +200,24 @@ const WalletConnectScreen: React.FC = () => { return icons[symbol] || '🪙'; }; - const getTokenPrice = (symbol: string): number => { - const prices: Record = { - ETH: 3500, - MATIC: 0.8, - USDC: 1.0, - ARB: 1.2, - }; - return prices[symbol] || 1.0; + const getTokenPriceId = (symbol: string): string => { + return TICKER_TO_COINGECKO_ID[symbol.toUpperCase()] ?? symbol.toLowerCase(); }; + const hasCachedPriceData = Object.values(prices).some((price) => price.available !== false); + const showPriceLoading = isPriceLoading && !hasCachedPriceData; + const showStalePriceWarning = Boolean(priceError) && hasCachedPriceData; + return ( - + + }> Connect Wallet Connect your Web3 wallet to enable crypto payments @@ -333,12 +353,28 @@ const WalletConnectScreen: React.FC = () => { Refresh + {showStalePriceWarning ? ( + + ⚠️ Prices may be stale. Showing the latest cached values while CoinGecko + refreshes. + + ) : null} + {lastUpdated ? ( + + Last price update: {new Date(lastUpdated).toLocaleTimeString()} + + ) : null} {isLoadingBalances ? ( Loading balances... + ) : showPriceLoading ? ( + + + Loading token prices... + ) : ( {tokenBalances.map((token, index) => ( @@ -356,9 +392,28 @@ const WalletConnectScreen: React.FC = () => { {parseFloat(token.balance).toFixed(4)} - - ≈ ${(parseFloat(token.balance) * getTokenPrice(token.symbol)).toFixed(2)} - + {(() => { + const priceId = getTokenPriceId(token.symbol); + const price = prices[priceId]; + const balance = parseFloat(token.balance); + const hasPrice = Boolean(price && price.available !== false); + + if (!hasPrice) { + return Price unavailable; + } + + return ( + <> + + ≈ ${(balance * price.usd).toFixed(2)} + + + {price.usd24hChange >= 0 ? '+' : ''} + {price.usd24hChange.toFixed(2)}% 24h + + + ); + })()} ))} @@ -704,6 +759,21 @@ const styles = StyleSheet.create({ ...typography.caption, color: colors.textSecondary, }, + tokenPriceChange: { + ...typography.caption, + color: colors.textSecondary, + marginTop: spacing.xs / 2, + }, + priceWarning: { + ...typography.caption, + color: colors.warning, + marginBottom: spacing.sm, + }, + priceMetaText: { + ...typography.caption, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, setupButton: { marginTop: spacing.md, }, diff --git a/src/screens/WalletConnectV2Screen.tsx b/src/screens/WalletConnectV2Screen.tsx index 9c1d5cbf..29add6cd 100644 --- a/src/screens/WalletConnectV2Screen.tsx +++ b/src/screens/WalletConnectV2Screen.tsx @@ -5,6 +5,7 @@ import { StyleSheet, SafeAreaView, ScrollView, + RefreshControl, TouchableOpacity, Alert, ActivityIndicator, @@ -20,6 +21,8 @@ import { colors, spacing, typography, borderRadius, shadows } from '../utils/con import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; import walletServiceManager, { WalletConnection, TokenBalance } from '../services/walletService'; +import { TICKER_TO_COINGECKO_ID } from '../services/priceService'; +import { useTokenPrices } from '../hooks/useTokenPrices'; import { useWalletStore } from '../store'; import { RootStackParamList } from '../navigation/types'; import { getWalletConnectChain, WALLETCONNECT_CHAINS } from '../services/walletconnect/chains'; @@ -44,6 +47,18 @@ const WalletConnectV2Screen: React.FC = () => { const [tokenBalances, setTokenBalances] = useState([]); const [isLoadingBalances, setIsLoadingBalances] = useState(false); const [sessionState, setSessionState] = useState(null); + const tokenPriceIds = tokenBalances.map((token) => token.symbol); + const { + prices, + isLoading: isPriceLoading, + isRefreshing, + error: priceError, + refresh, + lastUpdated, + } = useTokenPrices({ + tokenIds: tokenPriceIds, + enabled: tokenPriceIds.length > 0, + }); useEffect(() => { void initializeWalletService(); @@ -187,6 +202,7 @@ const WalletConnectV2Screen: React.FC = () => { const handleRefreshBalances = async () => { const balances = await loadTokenBalances(); setTokenBalances(balances); + await refresh(); }; const handleCopyAddress = async () => { @@ -241,16 +257,14 @@ const WalletConnectV2Screen: React.FC = () => { return icons[symbol] || symbol.slice(0, 4).toUpperCase(); }; - const getTokenPrice = (symbol: string): number => { - const prices: Record = { - ETH: 3500, - MATIC: 0.8, - USDC: 1.0, - ARB: 1.2, - }; - return prices[symbol] || 1.0; + const getTokenPriceId = (symbol: string): string => { + return TICKER_TO_COINGECKO_ID[symbol.toUpperCase()] ?? symbol.toLowerCase(); }; + const hasCachedPriceData = Object.values(prices).some((price) => price.available !== false); + const showPriceLoading = isPriceLoading && !hasCachedPriceData; + const showStalePriceWarning = Boolean(priceError) && hasCachedPriceData; + const pairingUri = useMemo( () => sessionState?.pairingUri || buildPairingUri(connection?.address, connection?.chainId), [sessionState?.pairingUri, connection?.address, connection?.chainId] @@ -273,7 +287,14 @@ const WalletConnectV2Screen: React.FC = () => { return ( - + + }> Connect Wallet @@ -426,12 +447,27 @@ const WalletConnectV2Screen: React.FC = () => { Refresh + {showStalePriceWarning ? ( + + ⚠️ Prices may be stale. Showing cached values while CoinGecko refreshes. + + ) : null} + {lastUpdated ? ( + + Last price update: {new Date(lastUpdated).toLocaleTimeString()} + + ) : null} {isLoadingBalances ? ( Loading balances... + ) : showPriceLoading ? ( + + + Loading token prices... + ) : ( {tokenBalances.map((token) => ( @@ -449,9 +485,28 @@ const WalletConnectV2Screen: React.FC = () => { {parseFloat(token.balance).toFixed(4)} - - ${(parseFloat(token.balance) * getTokenPrice(token.symbol)).toFixed(2)} - + {(() => { + const priceId = getTokenPriceId(token.symbol); + const price = prices[priceId]; + const balance = parseFloat(token.balance); + const hasPrice = Boolean(price && price.available !== false); + + if (!hasPrice) { + return Price unavailable; + } + + return ( + <> + + ≈ ${(balance * price.usd).toFixed(2)} + + + {price.usd24hChange >= 0 ? '+' : ''} + {price.usd24hChange.toFixed(2)}% 24h + + + ); + })()} ))} @@ -743,6 +798,21 @@ const styles = StyleSheet.create({ ...typography.caption, color: colors.textSecondary, }, + tokenPriceChange: { + ...typography.caption, + color: colors.textSecondary, + marginTop: spacing.xs / 2, + }, + priceWarning: { + ...typography.caption, + color: colors.warning, + marginBottom: spacing.sm, + }, + priceMetaText: { + ...typography.caption, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, }); export default WalletConnectV2Screen; diff --git a/src/services/priceService.test.ts b/src/services/priceService.test.ts new file mode 100644 index 00000000..9fe138a8 --- /dev/null +++ b/src/services/priceService.test.ts @@ -0,0 +1,238 @@ +import { + clearPriceCache, + fetchTokenPrices, + getTokenPrice, + TICKER_TO_COINGECKO_ID, + CACHE_TTL_MS, +} from './priceService'; + +type MockStorageHost = typeof globalThis & { + __priceServiceTestStorage?: Map; +}; + +function mockGetStorage(): Map { + const host = globalThis as MockStorageHost; + if (!host.__priceServiceTestStorage) { + host.__priceServiceTestStorage = new Map(); + } + return host.__priceServiceTestStorage; +} + +const mockFetch = jest.fn(); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn((key: string) => Promise.resolve(mockGetStorage().get(key) ?? null)), + setItem: jest.fn((key: string, value: string) => { + mockGetStorage().set(key, value); + return Promise.resolve(); + }), + removeItem: jest.fn((key: string) => { + mockGetStorage().delete(key); + return Promise.resolve(); + }), +})); + +type MockResponseInit = { + ok?: boolean; + status?: number; + statusText?: string; +}; + +function createResponse(body: unknown, init: MockResponseInit = {}): Response { + return { + ok: init.ok ?? true, + status: init.status ?? 200, + statusText: init.statusText ?? 'OK', + json: jest.fn().mockResolvedValue(body), + } as unknown as Response; +} + +beforeAll(() => { + global.fetch = mockFetch as unknown as typeof fetch; +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockGetStorage().clear(); + clearPriceCache(); +}); + +describe('priceService', () => { + it('returns real prices on successful API response', async () => { + mockFetch.mockResolvedValueOnce( + createResponse({ + bitcoin: { usd: 67000, usd_24h_change: 2.35 }, + }) + ); + + const result = await fetchTokenPrices(['BTC']); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.fromCache).toBe(false); + expect(result.error).toBeNull(); + expect(result.prices.bitcoin).toMatchObject({ + id: 'bitcoin', + usd: 67000, + usd24hChange: 2.35, + available: true, + }); + }); + + it('returns cached prices without calling fetch when cache is valid', async () => { + mockFetch.mockResolvedValueOnce( + createResponse({ + bitcoin: { usd: 67000, usd_24h_change: 2.35 }, + }) + ); + + const first = await fetchTokenPrices(['BTC']); + const second = await fetchTokenPrices(['BTC']); + + expect(first.fromCache).toBe(false); + expect(second.fromCache).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(second.prices.bitcoin.usd).toBe(67000); + }); + + it('returns stale cache with error when fetch fails', async () => { + const nowSpy = jest.spyOn(Date, 'now'); + nowSpy.mockReturnValue(1_000); + mockFetch.mockResolvedValueOnce( + createResponse({ + bitcoin: { usd: 67000, usd_24h_change: 2.35 }, + }) + ); + await fetchTokenPrices(['BTC']); + + nowSpy.mockReturnValue(1_000 + CACHE_TTL_MS + 1); + mockFetch.mockRejectedValueOnce(new Error('network error')); + + const result = await fetchTokenPrices(['BTC']); + + expect(result.fromCache).toBe(true); + expect(result.error).toBe('Unable to load prices'); + expect(result.prices.bitcoin.usd).toBe(67000); + expect(result.prices.bitcoin.available).toBe(true); + + nowSpy.mockRestore(); + }); + + it('returns error and empty prices when fetch fails and cache is empty', async () => { + mockFetch.mockRejectedValueOnce(new Error('offline')); + + const result = await fetchTokenPrices(['BTC']); + + expect(result.fromCache).toBe(true); + expect(result.error).toBe('Unable to load prices'); + expect(result.prices.bitcoin.usd).toBe(0); + expect(result.prices.bitcoin.available).toBe(false); + }); + + it('handles HTTP non-200 response', async () => { + mockFetch.mockResolvedValueOnce( + createResponse( + {}, + { + ok: false, + status: 429, + statusText: 'Too Many Requests', + } + ) + ); + + const result = await fetchTokenPrices(['BTC']); + + expect(result.error).toBe('rate limited'); + expect(result.fromCache).toBe(true); + expect(result.prices.bitcoin.available).toBe(false); + }); + + it('respects the 10s timeout', async () => { + jest.useFakeTimers(); + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + + mockFetch.mockImplementation((_input: RequestInfo | URL, init?: RequestInit) => { + const signal = init?.signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { + const abortError = new Error('Aborted'); + (abortError as Error & { name: string }).name = 'AbortError'; + reject(abortError); + }); + }); + }); + + const promise = fetchTokenPrices(['BTC']); + + await Promise.resolve(); + jest.advanceTimersByTime(10_001); + await Promise.resolve(); + + const result = await promise; + + expect(abortSpy).toHaveBeenCalled(); + expect(result.error).toBe('timeout'); + expect(result.fromCache).toBe(true); + + abortSpy.mockRestore(); + jest.useRealTimers(); + }); + + it('returns null for an unknown token', () => { + expect(getTokenPrice('does-not-exist')).toBeNull(); + }); + + it('returns cached entry for a known token', async () => { + mockFetch.mockResolvedValueOnce( + createResponse({ + bitcoin: { usd: 67000, usd_24h_change: 2.35 }, + }) + ); + + await fetchTokenPrices(['BTC']); + expect(getTokenPrice('bitcoin')).toMatchObject({ + id: 'bitcoin', + usd: 67000, + usd24hChange: 2.35, + available: true, + }); + }); + + it('empties the cache when cleared', async () => { + mockFetch.mockResolvedValueOnce( + createResponse({ + bitcoin: { usd: 67000, usd_24h_change: 2.35 }, + }) + ); + + await fetchTokenPrices(['BTC']); + clearPriceCache(); + + mockFetch.mockResolvedValueOnce( + createResponse({ + bitcoin: { usd: 68000, usd_24h_change: 1.1 }, + }) + ); + + await fetchTokenPrices(['BTC']); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('contains mappings for every token symbol used in the app', () => { + expect(TICKER_TO_COINGECKO_ID).toEqual( + expect.objectContaining({ + BTC: 'bitcoin', + ETH: 'ethereum', + XLM: 'stellar', + SOL: 'solana', + USDC: 'usd-coin', + BNB: 'binancecoin', + MATIC: 'polygon-ecosystem-token', + ARB: 'arbitrum', + DAI: 'dai', + WBTC: 'wrapped-bitcoin', + }) + ); + }); +}); diff --git a/src/services/priceService.ts b/src/services/priceService.ts new file mode 100644 index 00000000..b0d32dcd --- /dev/null +++ b/src/services/priceService.ts @@ -0,0 +1,340 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export interface TokenPrice { + id: string; + usd: number; + usd24hChange: number; + fetchedAt: number; + available?: boolean; +} + +export interface PriceServiceResult { + prices: Record; + fromCache: boolean; + error: string | null; +} + +type CoinGeckoPriceResponse = Record< + string, + { + usd?: number; + usd_24h_change?: number; + } +>; + +const COINGECKO_API_BASE_URL = 'https://api.coingecko.com/api/v3'; +export const CACHE_TTL_MS = 5 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 10_000; +const CACHE_STORAGE_KEY = '@subtrackr/price_cache'; + +export const TICKER_TO_COINGECKO_ID: Record = { + BTC: 'bitcoin', + ETH: 'ethereum', + XLM: 'stellar', + SOL: 'solana', + USDC: 'usd-coin', + BNB: 'binancecoin', + MATIC: 'polygon-ecosystem-token', + ARB: 'arbitrum', + DAI: 'dai', + WBTC: 'wrapped-bitcoin', +}; + +const memoryCache = new Map(); +let cacheHydrationGeneration = 0; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function clonePrice(entry: TokenPrice): TokenPrice { + return { ...entry }; +} + +function isCacheValid(entry: TokenPrice): boolean { + return Date.now() - entry.fetchedAt < CACHE_TTL_MS; +} + +function normalizeTokenId(tokenId: string): string { + const trimmed = tokenId.trim(); + if (!trimmed) { + return ''; + } + + const mapped = TICKER_TO_COINGECKO_ID[trimmed.toUpperCase()]; + return mapped ?? trimmed.toLowerCase(); +} + +function normalizeTokenIds(tokenIds: string[]): string[] { + const seen = new Set(); + const normalized: string[] = []; + + for (const tokenId of tokenIds) { + const resolved = normalizeTokenId(tokenId); + if (!resolved || seen.has(resolved)) { + continue; + } + seen.add(resolved); + normalized.push(resolved); + } + + return normalized; +} + +function buildEmptyTokenPrice(id: string): TokenPrice { + return { + id, + usd: 0, + usd24hChange: 0, + fetchedAt: Date.now(), + available: false, + }; +} + +function collectPrices(tokenIds: string[], includeStale = true): Record { + const prices: Record = {}; + + for (const tokenId of tokenIds) { + const cached = memoryCache.get(tokenId); + if (cached) { + if (includeStale || isCacheValid(cached)) { + prices[tokenId] = clonePrice(cached); + } + continue; + } + + if (includeStale) { + prices[tokenId] = buildEmptyTokenPrice(tokenId); + } + } + + return prices; +} + +function collectValidCachedPrices(tokenIds: string[]): Record { + const prices: Record = {}; + + for (const tokenId of tokenIds) { + const cached = memoryCache.get(tokenId); + if (cached && isCacheValid(cached)) { + prices[tokenId] = clonePrice(cached); + } + } + + return prices; +} + +function buildFailureResult(tokenIds: string[], error: string): PriceServiceResult { + const prices = collectPrices(tokenIds, true); + return { + prices, + fromCache: true, + error, + }; +} + +async function hydrateCacheFromStorage(): Promise { + const generation = ++cacheHydrationGeneration; + + try { + const stored = await AsyncStorage.getItem(CACHE_STORAGE_KEY); + if (generation !== cacheHydrationGeneration || !stored) { + return; + } + + const parsed = JSON.parse(stored) as unknown; + if (!isRecord(parsed)) { + return; + } + + memoryCache.clear(); + + for (const [id, value] of Object.entries(parsed)) { + if (!isRecord(value)) { + continue; + } + + const usd = typeof value.usd === 'number' ? value.usd : null; + const usd24hChange = + typeof value.usd24hChange === 'number' + ? value.usd24hChange + : typeof value.usd_24h_change === 'number' + ? value.usd_24h_change + : null; + const fetchedAt = typeof value.fetchedAt === 'number' ? value.fetchedAt : null; + + if (usd === null || usd24hChange === null || fetchedAt === null) { + continue; + } + + memoryCache.set(id, { + id, + usd, + usd24hChange, + fetchedAt, + available: value.available !== false, + }); + } + } catch (error) { + console.warn('Failed to hydrate cached token prices', error); + } +} + +async function persistCache(): Promise { + try { + const serialized = JSON.stringify(Object.fromEntries(memoryCache.entries())); + await AsyncStorage.setItem(CACHE_STORAGE_KEY, serialized); + } catch (error) { + console.warn('Failed to persist cached token prices', error); + } +} + +function buildHeaders(): Record { + const headers: Record = { + Accept: 'application/json', + }; + + const apiKey = + process.env.EXPO_PUBLIC_COINGECKO_API_KEY ?? + process.env.COINGECKO_API_KEY ?? + process.env.CG_DEMO_API_KEY; + + if (apiKey) { + headers['x-cg-demo-api-key'] = apiKey; + } + + return headers; +} + +function mapFetchErrorToMessage(error: unknown): string { + if (error instanceof Error) { + if (error.name === 'AbortError') { + return 'timeout'; + } + + const message = error.message.toLowerCase(); + if (message.includes('network')) { + return 'Unable to load prices'; + } + } + + return 'Unable to load prices'; +} + +export function getTokenPrice(tokenId: string): TokenPrice | null { + const normalized = normalizeTokenId(tokenId); + if (!normalized) { + return null; + } + + const cached = memoryCache.get(normalized); + return cached ? clonePrice(cached) : null; +} + +export function clearPriceCache(): void { + memoryCache.clear(); + cacheHydrationGeneration += 1; + void AsyncStorage.removeItem(CACHE_STORAGE_KEY); +} + +export async function fetchTokenPrices(tokenIds: string[]): Promise { + const normalizedIds = normalizeTokenIds(tokenIds); + if (normalizedIds.length === 0) { + return { + prices: {}, + fromCache: true, + error: null, + }; + } + + const validCachedPrices = collectValidCachedPrices(normalizedIds); + if (Object.keys(validCachedPrices).length === normalizedIds.length) { + return { + prices: validCachedPrices, + fromCache: true, + error: null, + }; + } + + const idsToFetch = normalizedIds.filter((tokenId) => { + const cached = memoryCache.get(tokenId); + return !cached || !isCacheValid(cached); + }); + + if (idsToFetch.length === 0) { + return { + prices: collectPrices(normalizedIds, true), + fromCache: true, + error: null, + }; + } + + const url = new URL(`${COINGECKO_API_BASE_URL}/simple/price`); + url.searchParams.set('ids', idsToFetch.join(',')); + url.searchParams.set('vs_currencies', 'usd'); + url.searchParams.set('include_24hr_change', 'true'); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url.toString(), { + signal: controller.signal, + headers: buildHeaders(), + }); + + if (response.status === 429) { + console.warn('CoinGecko rate limited the price request'); + return buildFailureResult(normalizedIds, 'rate limited'); + } + + if (!response.ok) { + console.warn('CoinGecko price request failed', response.status, response.statusText); + return buildFailureResult(normalizedIds, 'Unable to load prices'); + } + + let payload: CoinGeckoPriceResponse; + try { + payload = (await response.json()) as CoinGeckoPriceResponse; + } catch (error) { + console.warn('CoinGecko price response could not be parsed', error); + return buildFailureResult(normalizedIds, 'Unable to load prices'); + } + + if (!isRecord(payload)) { + console.warn('CoinGecko price response had an unexpected shape'); + return buildFailureResult(normalizedIds, 'Unable to load prices'); + } + + const fetchedAt = Date.now(); + + for (const tokenId of idsToFetch) { + const entry = payload[tokenId]; + if (entry && typeof entry.usd === 'number' && typeof entry.usd_24h_change === 'number') { + memoryCache.set(tokenId, { + id: tokenId, + usd: entry.usd, + usd24hChange: entry.usd_24h_change, + fetchedAt, + available: true, + }); + } + } + + await persistCache(); + + return { + prices: collectPrices(normalizedIds, true), + fromCache: false, + error: null, + }; + } catch (error) { + const message = mapFetchErrorToMessage(error); + console.warn('CoinGecko price request failed', error); + return buildFailureResult(normalizedIds, message); + } finally { + clearTimeout(timeout); + } +} + +void hydrateCacheFromStorage();