Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions src/hooks/useExchangeRate.ts
Original file line number Diff line number Diff line change
@@ -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<ExchangeRateState>({
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 }
}
60 changes: 60 additions & 0 deletions src/lib/exchangeRate.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
84 changes: 84 additions & 0 deletions src/lib/exchangeRate.ts
Original file line number Diff line number Diff line change
@@ -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<ExchangeRateResult> {
// 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' }
}