Agent Skills: Exchange Rate Service

Use when implementing exchange rate functionality - provides complete patterns for fetching BTC/fiat exchange rates from Coinbase API, caching strategies, conversion utilities, and React hooks for displaying rates in UI

UncategorizedID: PerceptLabs/nostril/exchange-rates

Install this agent skill to your local

pnpm dlx add-skill https://github.com/PerceptLabs/nostril/tree/HEAD/skills/exchange-rates

Skill Files

Browse the full folder contents for exchange-rates.

Download Skill

Loading file tree…

skills/exchange-rates/SKILL.md

Skill Metadata

Name
exchange-rates
Description
Use when implementing exchange rate functionality - provides complete patterns for fetching BTC/fiat exchange rates from Coinbase API, caching strategies, conversion utilities, and React hooks for displaying rates in UI

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.ok and handle accordingly
  • Network errors: Use stale cache fallback
  • Invalid responses: Validate structure before using

Summary

To implement exchange rate functionality:

  1. Create service - Module-level cache with Coinbase API integration
  2. Add conversion utilities - BTC↔USD, sats↔USD conversions
  3. Create React hooks - Use TanStack Query for caching and refetching
  4. Build UI components - Display rates with loading and error states
  5. Handle errors - Fallback to stale cache, show user-friendly messages
  6. Test thoroughly - Unit tests for service, integration tests for hooks

Key principle: Cache aggressively, validate responses, and always provide fallbacks for API failures.