forked from FlowwStar/FlowStar
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathuse-token-price.ts
More file actions
159 lines (132 loc) · 4.66 KB
/
Copy pathuse-token-price.ts
File metadata and controls
159 lines (132 loc) · 4.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
'use client'
import { useState, useEffect, useRef } from 'react'
import type { StreamData } from '@/types/stream'
interface TokenPrice {
usdPrice: number | null
lastUpdated: number | null
loading: boolean
stale: boolean
}
const PRICE_CACHE: Record<string, { price: number; fetchedAt: number }> = {}
const STALE_THRESHOLD_MS = 5 * 60 * 1000 // 5 minutes
const STALENESS_WARNING_MS = 2 * 60 * 1000 // warn if price is >2 min old on display
async function fetchXlmUsdPrice(): Promise<number> {
const res = await fetch('https://api.stellar.expert/explorer/public/asset/XLM/price', {
next: { revalidate: 60 },
})
if (!res.ok) throw new Error('price fetch failed')
const json = await res.json()
const price = Number(json.price ?? json.close ?? json.last)
if (!Number.isFinite(price)) {
throw new Error('price fetch returned invalid data')
}
return price
}
export function formatUsd(value: number): string {
if (value < 1) {
return '$' + value.toFixed(4)
}
if (value < 1000) {
return '$' + value.toFixed(2)
}
return (
'$' +
value.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
)
}
export function useTokenPrice(symbol: string): TokenPrice {
const [price, setPrice] = useState<number | null>(null)
const [fetchedAt, setFetchedAt] = useState<number | null>(null)
const [loading, setLoading] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isStablecoin = symbol === 'USDC' || symbol === 'EURC'
useEffect(() => {
if (isStablecoin) {
setPrice(1)
setFetchedAt(Date.now())
return
}
if (symbol !== 'XLM') {
setPrice(null)
setFetchedAt(null)
return
}
const cached = PRICE_CACHE[symbol]
if (cached && Date.now() - cached.fetchedAt < STALE_THRESHOLD_MS) {
setPrice(cached.price)
setFetchedAt(cached.fetchedAt)
return
}
setLoading(true)
fetchXlmUsdPrice()
.then((p) => {
PRICE_CACHE[symbol] = { price: p, fetchedAt: Date.now() }
setPrice(p)
setFetchedAt(Date.now())
})
.catch(() => {
// Keep showing the last known-good price (if any) instead of
// clobbering it with NaN/null on a malformed or failed response.
if (cached) {
setPrice(cached.price)
setFetchedAt(cached.fetchedAt)
} else {
setPrice(null)
}
})
.finally(() => setLoading(false))
timerRef.current = setTimeout(() => {
setPrice(null)
setFetchedAt(null)
}, STALE_THRESHOLD_MS)
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [symbol, isStablecoin])
const stale = fetchedAt !== null && Date.now() - fetchedAt > STALENESS_WARNING_MS
return { usdPrice: price, lastUpdated: fetchedAt, loading, stale }
}
interface PortfolioValue {
totalUsd: number | null
loading: boolean
stale: boolean
}
// Internal hook to get price for a single symbol — called conditionally per unique symbol.
// We collect unique symbols and call useTokenPrice for each (hooks must not be called conditionally,
// so we support up to a fixed set of known symbols).
// We support a fixed set of symbols to avoid conditional hook calls.
const KNOWN_SYMBOLS = ['XLM', 'USDC', 'EURC']
export function usePortfolioValue(streams: StreamData[]): PortfolioValue {
const xlm = useTokenPrice('XLM')
const usdc = useTokenPrice('USDC')
const eurc = useTokenPrice('EURC')
const priceMap: Record<string, TokenPrice> = {
XLM: xlm,
USDC: usdc,
EURC: eurc,
}
const usedSymbols = [...new Set(streams.map((s) => s.token.symbol))]
const loading = usedSymbols.some((sym) => KNOWN_SYMBOLS.includes(sym) && priceMap[sym]?.loading)
const stale = usedSymbols.some((sym) => KNOWN_SYMBOLS.includes(sym) && priceMap[sym]?.stale)
let totalUsd: number | null = 0
let hasKnownToken = false
for (const stream of streams) {
const symbol = stream.token.symbol
const tp = priceMap[symbol]
// Only process known symbols with available prices
if (KNOWN_SYMBOLS.includes(symbol) && tp?.usdPrice !== null) {
hasKnownToken = true
const locked = stream.depositedAmount - stream.withdrawnAmount
const human = Number(locked) / Math.pow(10, stream.token.decimals)
totalUsd = (totalUsd ?? 0) + human * tp.usdPrice
}
// Skip unknown tokens — don't null out the entire portfolio
}
// Only return null if there are streams but none of them are known tokens.
// An empty portfolio is $0, not "unpriceable".
const finalTotalUsd = streams.length === 0 || hasKnownToken ? totalUsd : null
return { totalUsd: finalTotalUsd, loading, stale }
}