diff --git a/src/hooks/useExchangeRate.ts b/src/hooks/useExchangeRate.ts index b0194cb..245590d 100644 --- a/src/hooks/useExchangeRate.ts +++ b/src/hooks/useExchangeRate.ts @@ -1,12 +1,62 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef, useContext } from 'react' +import { fetchExchangeRate, DEFAULT_FALLBACK_XLM_USD } from '@/lib/exchangeRate' +import { useWalletContext } from '@/context/WalletContext' + +export interface ExchangeRateState { + xlmUsd: number + source?: 'coingecko' | 'horizon' | 'fallback' +} + +/** + * Hook to retrieve live XLM/USD spot exchange rate. + * Polls every 30s while respecting the active network (if within WalletProvider). + */ export function useExchangeRate() { - const [rate, setRate] = useState<{ xlmUsd: number } | null>(null) + let network: 'testnet' | 'mainnet' = 'mainnet' + try { + const walletCtx = useWalletContext() + network = walletCtx.wallet.network + } catch { + // Gracefully handle usage outside WalletProvider in isolated tests or standalone components + network = 'mainnet' + } + + const [rate, setRate] = useState({ + xlmUsd: DEFAULT_FALLBACK_XLM_USD, + source: 'fallback', + }) const [loading, setLoading] = useState(true) + const isMountedRef = useRef(true) + useEffect(() => { - const update = () => setRate({ xlmUsd: 0.12 }) - setLoading(true); setTimeout(() => { update(); setLoading(false) }, 200) - const t = setInterval(update, 30_000) - return () => clearInterval(t) - }, []) + isMountedRef.current = true + + let isFirst = true + const updateRate = async () => { + try { + if (isFirst) setLoading(true) + const result = await fetchExchangeRate(network) + if (isMountedRef.current) { + setRate(result) + setLoading(false) + } + } catch { + if (isMountedRef.current) { + setLoading(false) + } + } finally { + isFirst = false + } + } + + updateRate() + const timer = setInterval(updateRate, 30_000) + + return () => { + isMountedRef.current = false + clearInterval(timer) + } + }, [network]) + return { rate, loading } } diff --git a/src/lib/exchangeRate.test.ts b/src/lib/exchangeRate.test.ts new file mode 100644 index 0000000..9f5b934 --- /dev/null +++ b/src/lib/exchangeRate.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fetchExchangeRate, DEFAULT_FALLBACK_XLM_USD } from './exchangeRate' + +describe('fetchExchangeRate', () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + vi.restoreAllMocks() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('fetches price from CoinGecko when available', async () => { + globalThis.fetch = vi.fn().mockImplementation((url: string) => { + if (url.includes('coingecko.com')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ stellar: { usd: 0.1542 } }), + }) + } + return Promise.reject(new Error('Unknown url')) + }) + + const res = await fetchExchangeRate('mainnet') + expect(res.xlmUsd).toBe(0.1542) + expect(res.source).toBe('coingecko') + }) + + it('falls back to Horizon DEX on mainnet when CoinGecko fails', async () => { + globalThis.fetch = vi.fn().mockImplementation((url: string) => { + if (url.includes('coingecko.com')) { + return Promise.resolve({ + ok: false, + status: 429, + }) + } + if (url.includes('horizon.stellar.org/order_book')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ bids: [{ price: '0.1480' }] }), + }) + } + return Promise.reject(new Error('Unknown url')) + }) + + const res = await fetchExchangeRate('mainnet') + expect(res.xlmUsd).toBe(0.148) + expect(res.source).toBe('horizon') + }) + + it('returns default fallback when all sources fail', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network offline')) + + const res = await fetchExchangeRate('testnet') + expect(res.xlmUsd).toBe(DEFAULT_FALLBACK_XLM_USD) + expect(res.source).toBe('fallback') + }) +}) diff --git a/src/lib/exchangeRate.ts b/src/lib/exchangeRate.ts new file mode 100644 index 0000000..ba50a45 --- /dev/null +++ b/src/lib/exchangeRate.ts @@ -0,0 +1,84 @@ +import { horizonUrl } from './api' + +export interface ExchangeRateResult { + xlmUsd: number + source: 'coingecko' | 'horizon' | 'fallback' +} + +/** Fallback price when external API and DEX are unreachable. */ +export const DEFAULT_FALLBACK_XLM_USD = 0.12 + +// Standard Mainnet USDC issuer on Stellar +const MAINNET_USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' + +/** + * Fetch live XLM/USD exchange rate. + * Tries CoinGecko spot price first, then Horizon DEX orderbook (USDC/XLM) on mainnet, + * and falls back gracefully to default placeholder if network fails. + */ +export async function fetchExchangeRate( + network: 'testnet' | 'mainnet' = 'mainnet', +): Promise { + // 1. Try CoinGecko public simple price API (CORS friendly) + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 4000) + + const res = await fetch( + 'https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd', + { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }, + ) + clearTimeout(timeoutId) + + if (res.ok) { + const data = await res.json() + const price = data?.stellar?.usd + if (typeof price === 'number' && price > 0) { + return { xlmUsd: price, source: 'coingecko' } + } + } + } catch { + // Ignore CoinGecko rate limit / network error and proceed to fallback/orderbook + } + + // 2. Try Stellar DEX orderbook (USDC/XLM) on Mainnet Horizon + if (network === 'mainnet') { + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 4000) + + const base = horizonUrl('mainnet') + const params = new URLSearchParams({ + selling_asset_type: 'native', + buying_asset_type: 'credit_alphanum4', + buying_asset_code: 'USDC', + buying_asset_issuer: MAINNET_USDC_ISSUER, + limit: '1', + }) + + const res = await fetch(`${base}/order_book?${params}`, { + signal: controller.signal, + }) + clearTimeout(timeoutId) + + if (res.ok) { + const data = await res.json() + const topBid = data?.bids?.[0]?.price + const topAsk = data?.asks?.[0]?.price + const priceNum = topBid ? parseFloat(topBid) : topAsk ? parseFloat(topAsk) : null + + if (priceNum && !Number.isNaN(priceNum) && priceNum > 0) { + return { xlmUsd: priceNum, source: 'horizon' } + } + } + } catch { + // Horizon DEX query failed + } + } + + // 3. Fallback placeholder + return { xlmUsd: DEFAULT_FALLBACK_XLM_USD, source: 'fallback' } +}