Exchange Rate Service
Overview
Complete implementation guide for fetching and using Bitcoin exchange rates from Coinbase API. Includes caching strategies, conversion utilities, and React hooks for displaying rates in wallet UIs.
Core Capabilities:
- Fetch BTC exchange rates from Coinbase API
- Module-level caching to reduce API calls
- Convert between BTC, sats, and fiat currencies
- React hooks for real-time rate display
- Error handling with stale cache fallback
- Support for all major fiat currencies
Key Architecture Pattern: Module-level cache with configurable duration prevents excessive API calls while ensuring fresh data.
Prerequisites
No additional packages required - Uses native fetch API.
Coinbase API:
- Base URL:
https://api.coinbase.com/v2/exchange-rates - No authentication required
- Rate limits: Fair use policy (implement caching to respect limits)
Implementation Checklist
- [ ] Create exchange rate service with caching
- [ ] Implement BTC/fiat rate fetching
- [ ] Add conversion utilities (BTC↔USD, sats↔USD)
- [ ] Create React hook for exchange rates
- [ ] Add error handling and stale cache fallback
- [ ] Implement UI components for rate display
- [ ] Add loading states and error states
Part 1: Exchange Rate Service
Core Service Implementation
// lib/exchangeRateService.ts
/**
* Exchange rate service for fetching BTC/USD rates from Coinbase API
*
* Module-level cache to avoid excessive API calls
*/
const COINBASE_API_URL = "https://api.coinbase.com/v2/exchange-rates";
const CACHE_DURATION_MS = 60000; // 1 minute cache
let cachedRate: number | null = null;
let cacheTimestamp: number = 0;
/**
* Get the current BTC/USD exchange rate from Coinbase API
* Uses caching to avoid excessive API calls
*
* @returns Promise resolving to BTC/USD exchange rate
*/
export async function getBtcUsdRate(): Promise<number> {
const now = Date.now();
// Return cached rate if still valid
if (cachedRate && (now - cacheTimestamp) < CACHE_DURATION_MS) {
console.debug(`Using cached BTC/USD rate: ${cachedRate}`, {
cache_age_ms: now - cacheTimestamp,
});
return cachedRate;
}
try {
console.debug("Fetching BTC/USD rate from Coinbase API");
const response = await fetch(`${COINBASE_API_URL}?currency=BTC`, {
method: "GET",
headers: {
"Accept": "application/json",
"User-Agent": "YourApp/1.0",
},
// Add timeout to prevent hanging
signal: AbortSignal.timeout(10000), // 10 second timeout
});
if (!response.ok) {
throw new Error(`Coinbase API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
// Validate response structure
if (!data?.data?.rates?.USD) {
throw new Error("Invalid response format from Coinbase API");
}
const rate = parseFloat(data.data.rates.USD);
if (isNaN(rate) || rate <= 0) {
throw new Error(`Invalid exchange rate received: ${data.data.rates.USD}`);
}
// Update cache
cachedRate = rate;
cacheTimestamp = now;
console.info(`Fetched BTC/USD rate from Coinbase: ${rate}`, { rate });
return rate;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Failed to fetch BTC/USD rate: ${errorMessage}`);
// If we have a cached rate, use it as fallback
if (cachedRate) {
const cacheAge = now - cacheTimestamp;
console.warn(`Using stale cached rate as fallback: ${cachedRate}`, {
cache_age_ms: cacheAge,
});
return cachedRate;
}
// If no cached rate available, throw error
throw new Error(`Unable to fetch BTC/USD exchange rate: ${errorMessage}`);
}
}
Key Features:
- Module-level cache - Shared across all calls, prevents duplicate requests
- Configurable cache duration - Default 1 minute, adjustable
- Stale cache fallback - Uses old rate if API fails
- Timeout protection - 10 second timeout prevents hanging
- Response validation - Validates API response structure and values
Conversion Utilities
// lib/exchangeRateService.ts
/**
* Convert BTC amount to USD using current exchange rate
*
* @param btcAmount - Amount in BTC
* @returns Promise resolving to USD amount
*/
export async function btcToUsd(btcAmount: number): Promise<number> {
const rate = await getBtcUsdRate();
return btcAmount * rate;
}
/**
* Convert USD amount to BTC using current exchange rate
*
* @param usdAmount - Amount in USD
* @returns Promise resolving to BTC amount
*/
export async function usdToBtc(usdAmount: number): Promise<number> {
const rate = await getBtcUsdRate();
return usdAmount / rate;
}
/**
* Convert sats to USD using current exchange rate
*
* @param sats - Amount in satoshis
* @returns Promise resolving to USD amount
*/
export async function satsToUsd(sats: number): Promise<number> {
const btcAmount = sats / 100000000; // Convert sats to BTC
return btcToUsd(btcAmount);
}
/**
* Convert USD to sats using current exchange rate
*
* @param usdAmount - Amount in USD
* @returns Promise resolving to satoshis
*/
export async function usdToSats(usdAmount: number): Promise<number> {
const btcAmount = await usdToBtc(usdAmount);
return Math.floor(btcAmount * 100000000); // Convert BTC to sats
}
Multi-Currency Support
Available Fiat Currencies:
Coinbase API supports all major fiat currencies. Common examples include:
Major Currencies:
- USD (US Dollar)
- EUR (Euro)
- GBP (British Pound)
- JPY (Japanese Yen)
- CNY (Chinese Yuan)
- CAD (Canadian Dollar)
- AUD (Australian Dollar)
- CHF (Swiss Franc)
- SEK (Swedish Krona)
- NOK (Norwegian Krone)
- DKK (Danish Krone)
- PLN (Polish Zloty)
- CZK (Czech Koruna)
- HUF (Hungarian Forint)
- RON (Romanian Leu)
- BGN (Bulgarian Lev)
- HRK (Croatian Kuna)
Other Supported Currencies:
- AED, AFN, ALL, AMD, ANG, AOA, ARS, AWG, AZN
- BBD, BDT, BHD, BIF, BMD, BND, BOB, BSD, BTN, BWP, BYN, BZD
- CDF, CLP, COP, CRC, CUP, CVE, CZK
- DJF, DOP, DZD
- EGP, ETB, EUR
- FJD, FKP
- GEL, GHS, GIP, GMD, GNF, GTQ, GYD
- HKD, HNL, HTG
- IDR, ILS, INR, IQD, IRR, ISK
- JMD, JOD, JPY
- KES, KGS, KHR, KMF, KPW, KRW, KWD, KYD, KZT
- LAK, LBP, LKR, LRD, LSL, LYD
- MAD, MDL, MGA, MKD, MMK, MNT, MOP, MRO, MRU, MUR, MVR, MWK, MXN, MYR, MZN
- NAD, NGN, NIO, NPR, NZD
- OMR
- PAB, PEN, PGK, PHP, PKR, PLN, PYG
- QAR
- RON, RSD, RUB, RWF
- SAR, SBD, SCR, SDG, SEK, SGD, SHP, SLL, SOS, SRD, STD, SVC, SYP, SZL
- THB, TJS, TMM, TMT, TND, TOP, TRY, TTD, TWD, TZS
- UAH, UGX, UYU, UZS
- VEF, VES, VND, VUV
- WST
- XAF, XCD, XOF, XPF
- YER
- ZAR, ZMK, ZMW, ZWD
Note: The API response includes rates for all currencies. To fetch a specific currency rate:
/**
* Get BTC exchange rate for any supported currency
*
* @param currencyCode - ISO currency code (e.g., 'EUR', 'GBP', 'JPY')
* @returns Promise resolving to BTC/[currency] exchange rate
*/
export async function getBtcRate(currencyCode: string): Promise<number> {
const response = await fetch(`${COINBASE_API_URL}?currency=BTC`, {
method: "GET",
headers: {
"Accept": "application/json",
},
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
throw new Error(`Coinbase API error: ${response.status}`);
}
const data = await response.json();
if (!data?.data?.rates?.[currencyCode]) {
throw new Error(`Currency ${currencyCode} not supported`);
}
const rate = parseFloat(data.data.rates[currencyCode]);
if (isNaN(rate) || rate <= 0) {
throw new Error(`Invalid exchange rate for ${currencyCode}`);
}
return rate;
}
Cache Management
// lib/exchangeRateService.ts
/**
* Clear the cached exchange rate (useful for testing or forcing refresh)
*/
export function clearCache(): void {
cachedRate = null;
cacheTimestamp = 0;
}
/**
* Get cache status for debugging
*
* @returns Cache status object
*/
export function getCacheStatus(): {
rate: number | null;
age_ms: number;
is_valid: boolean
} {
const now = Date.now();
const age = now - cacheTimestamp;
const isValid = cachedRate !== null && age < CACHE_DURATION_MS;
return {
rate: cachedRate,
age_ms: age,
is_valid: isValid,
};
}
Part 2: React Hooks
Exchange Rate Hook
// hooks/useExchangeRate.ts
import { useQuery } from '@tanstack/react-query';
import { getBtcUsdRate } from '@/lib/exchangeRateService';
/**
* Hook to fetch and cache BTC/USD exchange rate
*
* @example
* ```tsx
* const { data: rate, isLoading } = useExchangeRate();
*
* if (rate) {
* const usdValue = balanceSats / 100000000 * rate;
* }
* ```
*/
export function useExchangeRate() {
return useQuery({
queryKey: ['btc-usd-rate'],
queryFn: getBtcUsdRate,
staleTime: 60000, // Consider stale after 1 minute
refetchInterval: 120000, // Refetch every 2 minutes
retry: 2,
retryDelay: 1000,
});
}
Conversion Hook
// hooks/useExchangeRate.ts
import { useMemo } from 'react';
import { useExchangeRate } from './useExchangeRate';
/**
* Hook to convert sats to USD with current exchange rate
*
* @param sats - Amount in satoshis
* @returns USD amount or null if rate unavailable
*/
export function useSatsToUsd(sats: number | null | undefined) {
const { data: rate } = useExchangeRate();
return useMemo(() => {
if (!sats || !rate) return null;
return (sats / 100000000) * rate;
}, [sats, rate]);
}
/**
* Hook to convert USD to sats with current exchange rate
*
* @param usd - Amount in USD
* @returns Satoshis or null if rate unavailable
*/
export function useUsdToSats(usd: number | null | undefined) {
const { data: rate } = useExchangeRate();
return useMemo(() => {
if (!usd || !rate) return null;
return Math.floor((usd / rate) * 100000000);
}, [usd, rate]);
}
Part 3: UI Components
Exchange Rate Display
// components/ExchangeRateDisplay.tsx
import { useExchangeRate } from '@/hooks/useExchangeRate';
import { Skeleton } from '@/components/ui/skeleton';
export function ExchangeRateDisplay() {
const { data: rate, isLoading, error } = useExchangeRate();
if (isLoading) {
return <Skeleton className="h-6 w-32" />;
}
if (error) {
return (
<span className="text-sm text-muted-foreground">
Rate unavailable
</span>
);
}
if (!rate) {
return null;
}
return (
<div className="text-sm">
<span className="text-muted-foreground">BTC/USD: </span>
<span className="font-mono">${rate.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}</span>
</div>
);
}
Balance with USD Equivalent
// components/BalanceDisplay.tsx
import { useSatsToUsd } from '@/hooks/useExchangeRate';
import { Skeleton } from '@/components/ui/skeleton';
interface BalanceDisplayProps {
sats: number;
}
export function BalanceDisplay({ sats }: BalanceDisplayProps) {
const usdValue = useSatsToUsd(sats);
return (
<div className="space-y-1">
<div className="text-2xl font-bold">
{sats.toLocaleString()} sats
</div>
{usdValue !== null ? (
<div className="text-sm text-muted-foreground">
≈ ${usdValue.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</div>
) : (
<Skeleton className="h-4 w-24" />
)}
</div>
);
}
Formatted Currency Display
// components/CurrencyDisplay.tsx
import { useSatsToUsd } from '@/hooks/useExchangeRate';
interface CurrencyDisplayProps {
sats: number;
showSats?: boolean;
showUsd?: boolean;
}
export function CurrencyDisplay({
sats,
showSats = true,
showUsd = true
}: CurrencyDisplayProps) {
const usdValue = useSatsToUsd(sats);
return (
<div className="flex flex-col gap-1">
{showSats && (
<span className="font-mono">
{sats.toLocaleString()} sats
</span>
)}
{showUsd && usdValue !== null && (
<span className="text-sm text-muted-foreground">
${usdValue.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
)}
</div>
);
}
Common Pitfalls
1. ❌ Not implementing caching
Problem: Fetching exchange rate on every render causes excessive API calls and rate limiting.
Solution: Use module-level cache with configurable duration:
const CACHE_DURATION_MS = 60000; // 1 minute
let cachedRate: number | null = null;
let cacheTimestamp: number = 0;
2. ❌ Not handling API failures
Problem: API failures break the UI completely.
Solution: Always provide fallback to stale cache:
if (cachedRate) {
console.warn('Using stale cached rate as fallback');
return cachedRate;
}
3. ❌ No timeout protection
Problem: Slow or hanging API calls freeze the UI.
Solution: Add timeout to fetch requests:
signal: AbortSignal.timeout(10000), // 10 second timeout
4. ❌ Not validating API response
Problem: Invalid or unexpected API responses cause runtime errors.
Solution: Validate response structure:
if (!data?.data?.rates?.USD) {
throw new Error("Invalid response format");
}
const rate = parseFloat(data.data.rates.USD);
if (isNaN(rate) || rate <= 0) {
throw new Error("Invalid exchange rate");
}
5. ❌ Incorrect satoshi conversion
Problem: Wrong conversion between sats and BTC causes incorrect amounts.
Solution: Always use correct conversion factors:
// BTC to sats
const sats = btc * 100000000;
// Sats to BTC
const btc = sats / 100000000;
6. ❌ Not handling loading states
Problem: UI shows incorrect values while rate is loading.
Solution: Show loading skeleton or null until rate is available:
const { data: rate, isLoading } = useExchangeRate();
if (isLoading) {
return <Skeleton className="h-6 w-32" />;
}
Testing Strategy
Unit Tests
// lib/exchangeRateService.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { getBtcUsdRate, clearCache, getCacheStatus } from './exchangeRateService';
describe('Exchange Rate Service', () => {
beforeEach(() => {
clearCache();
vi.clearAllMocks();
});
describe('getBtcUsdRate', () => {
it('fetches rate from Coinbase API', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: {
rates: {
USD: '50000.00',
},
},
}),
});
const rate = await getBtcUsdRate();
expect(rate).toBe(50000);
});
it('uses cached rate when available', async () => {
// First call
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: { rates: { USD: '50000.00' } },
}),
});
await getBtcUsdRate();
const firstCallCount = vi.mocked(global.fetch).mock.calls.length;
// Second call within cache window
const rate = await getBtcUsdRate();
const secondCallCount = vi.mocked(global.fetch).mock.calls.length;
expect(rate).toBe(50000);
expect(secondCallCount).toBe(firstCallCount); // No new API call
});
it('falls back to stale cache on API failure', async () => {
// First call succeeds
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
data: { rates: { USD: '50000.00' } },
}),
});
await getBtcUsdRate();
// Second call fails
global.fetch = vi.fn().mockRejectedValueOnce(new Error('Network error'));
const rate = await getBtcUsdRate();
expect(rate).toBe(50000); // Uses stale cache
});
it('throws error when no cache available and API fails', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
await expect(getBtcUsdRate()).rejects.toThrow('Unable to fetch');
});
it('validates API response structure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ invalid: 'structure' }),
});
await expect(getBtcUsdRate()).rejects.toThrow('Invalid response format');
});
});
describe('getCacheStatus', () => {
it('returns cache status', () => {
const status = getCacheStatus();
expect(status).toHaveProperty('rate');
expect(status).toHaveProperty('age_ms');
expect(status).toHaveProperty('is_valid');
});
});
});
Integration Tests
// hooks/useExchangeRate.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { useExchangeRate } from './useExchangeRate';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
describe('useExchangeRate', () => {
it('fetches exchange rate', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: { rates: { USD: '50000.00' } },
}),
});
const { result } = renderHook(() => useExchangeRate(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toBe(50000);
});
});
API Reference
Coinbase Exchange Rates API
Endpoint: GET https://api.coinbase.com/v2/exchange-rates?currency=BTC
Response Format:
{
"data": {
"currency": "BTC",
"rates": {
"USD": "50000.00",
"EUR": "45000.00",
// ... all supported currencies
}
}
}
Rate Limits:
- No official rate limit documented
- Implement caching to respect fair use
- Recommended: Cache for 1-5 minutes
Error Handling:
- 4xx/5xx responses: Check
response.okand handle accordingly - Network errors: Use stale cache fallback
- Invalid responses: Validate structure before using
Summary
To implement exchange rate functionality:
- Create service - Module-level cache with Coinbase API integration
- Add conversion utilities - BTC↔USD, sats↔USD conversions
- Create React hooks - Use TanStack Query for caching and refetching
- Build UI components - Display rates with loading and error states
- Handle errors - Fallback to stale cache, show user-friendly messages
- Test thoroughly - Unit tests for service, integration tests for hooks
Key principle: Cache aggressively, validate responses, and always provide fallbacks for API failures.