Fix issues with the incorrect wallet data (#877)

* Enhance token data handling by integrating CoinGecko markets API

* Update token market data retrieval to use current price from CoinGecko API

* refactor: refactor wallet assets business logic for better readability

* fix: correct spelling of fully diluted in asset metadata

* fix: update fallback logic for total supply

* docs: add changeset

* chore: update React and ReactDOM to version 19.1.2 across multiple packages
This commit is contained in:
Jinho Jang
2025-12-08 22:18:47 +09:00
committed by GitHub
parent 9c3823bf25
commit 850022297d
14 changed files with 1353 additions and 1333 deletions
@@ -0,0 +1,5 @@
---
'@status-im/wallet': patch
---
Optimize CoinGecko API endpoints for market data by using `/simple/price` and `/coins/markets` instead of `/coins/${id}`. Keep `/coins/${id}` only for token descriptions. Improve market cap data freshness and fix fully diluted calculation.
+2 -2
View File
@@ -20,8 +20,8 @@
"@trpc/next": "10.45.2",
"@trpc/server": "10.45.2",
"next": "15.3.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.2",
"react-dom": "19.1.2",
"zod": "^3.23.8"
},
"devDependencies": {
+2 -2
View File
@@ -32,8 +32,8 @@
"next": "15.3.6",
"next-mdx-remote": "^5.0.0",
"pino-pretty": "^13.1.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.2",
"react-dom": "19.1.2",
"react-hook-form": "^7.45.1",
"rehype-slug": "^6.0.0",
"siwe": "^2.3.2",
+2 -2
View File
@@ -94,10 +94,10 @@
"parallax-controller": "^1.7.1",
"prettier-plugin-tailwindcss": "^0.6.6",
"qrcode.react": "^3.1.0",
"react": "19.1.0",
"react": "19.1.2",
"react-children-utilities": "^2.9.0",
"react-day-picker": "^8.7.1",
"react-dom": "19.1.0",
"react-dom": "19.1.2",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.45.1",
"react-remove-scroll": "^2.5.5",
@@ -192,13 +192,13 @@ async function Token({
label: 'Fully diluted',
value: (
<CurrencyAmount
value={metadata.fully_dilluted}
value={metadata.fully_diluted}
format="compact"
/>
),
tooltip: (
<CurrencyAmount
value={metadata.fully_dilluted}
value={metadata.fully_diluted}
format="standard"
/>
),
+2 -2
View File
@@ -116,10 +116,10 @@
"parallax-controller": "^1.7.1",
"prettier-plugin-tailwindcss": "^0.6.6",
"qrcode.react": "^3.1.0",
"react": "19.1.0",
"react": "19.1.2",
"react-children-utilities": "^2.9.0",
"react-day-picker": "^8.7.1",
"react-dom": "19.1.0",
"react-dom": "19.1.2",
"react-hook-form": "^7.45.1",
"react-remove-scroll": "^2.5.5",
"react-scroll-parallax": "^3.4.5",
+2 -2
View File
@@ -21,8 +21,8 @@
"framer-motion": "^12.0.6",
"next": "15.1.9",
"next-mdx-remote": "^5.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.2",
"react-dom": "19.1.2",
"rehype-slug": "^6.0.0",
"ts-pattern": "^5.6.2",
"zod": "^3.24.1"
+2 -2
View File
@@ -52,8 +52,8 @@
"class-variance-authority": "^0.7.1",
"ethers": "^6.15.0",
"long": "^5.3.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.2",
"react-dom": "19.1.2",
"react-hook-form": "^7.54.2",
"rehype-react": "^8.0.0",
"rehype-stringify": "^10.0.0",
@@ -660,13 +660,13 @@ const Token = (props: Props) => {
label: 'Fully diluted',
value: (
<CurrencyAmount
value={metadata.fully_dilluted}
value={metadata.fully_diluted}
format="compact"
/>
),
tooltip: (
<CurrencyAmount
value={metadata.fully_dilluted}
value={metadata.fully_diluted}
format="standard"
/>
),
+2 -2
View File
@@ -78,8 +78,8 @@
"eslint-plugin-tailwindcss": "^3.17.4",
"globals": "^15.12.0",
"postcss": "^8.4.33",
"react": "^19.1.0",
"react-dom": "^19.1.1",
"react": "^19.1.2",
"react-dom": "^19.1.2",
"rollup-plugin-preserve-directives": "^0.4.0",
"tailwind-scrollbar-utilities": "^0.2.0",
"tailwindcss": "^3.4.11",
+124 -121
View File
@@ -12,13 +12,17 @@ import {
} from '../../services/alchemy'
import {
COINGECKO_REVALIDATION_TIMES,
fetchTokenMarkets,
fetchTokenMetadata,
fetchTokenPriceHistory,
fetchTokensPrice,
} from '../../services/coingecko/index'
import { publicProcedure, router } from '../lib/trpc'
import type { CoinGeckoCoinDetailResponse } from '../../services/coingecko/types'
import type {
CoinGeckoCoinDetailResponse,
CoinGeckoMarketsResponse,
} from '../../services/coingecko/types'
import type { NetworkType } from '../types'
type ERC20Token = (typeof erc20TokenList)['tokens'][number]
@@ -35,7 +39,7 @@ type Asset = {
decimals?: number
metadata: {
market_cap: number
fully_dilluted: number
fully_diluted: number
circulation: number
total_supply: number
all_time_high: number
@@ -87,36 +91,6 @@ export const DEFAULT_TOKEN_IDS: Record<string, string> = {
SHIB: 'shiba-inu',
}
const DEFAULT_TOKEN_METADATA: Partial<CoinGeckoCoinDetailResponse> = {
market_data: {
current_price: { usd: 0 },
market_cap: { usd: 0 },
total_volume: { usd: 0 },
price_change_24h: 0,
price_change_percentage_24h: 0,
circulating_supply: 0,
total_supply: 0,
max_supply: null,
},
description: {},
links: {
homepage: [],
blockchain_site: [],
whitepaper: null,
},
}
async function fetchTokenMetadataWithFallback(symbol: string) {
try {
return await fetchTokenMetadata(
symbol,
COINGECKO_REVALIDATION_TIMES.TOKEN_METADATA,
)
} catch {
return DEFAULT_TOKEN_METADATA
}
}
function buildTokenSummary(
assets: Asset[],
defaultIcon: string,
@@ -156,6 +130,34 @@ function filterTokensByNetworks<T extends { chainId: number }>(
)
}
/**
* Fetch all CoinGecko data for a token (price, price history, markets, metadata)
*/
async function fetchTokenData(symbol: string) {
const [price, priceHistory, tokenMarkets, tokenMetadata] = await Promise.all([
fetchTokensPrice([symbol], COINGECKO_REVALIDATION_TIMES.CURRENT_PRICE).then(
prices => prices[symbol],
),
fetchTokenPriceHistory(
symbol,
'all',
COINGECKO_REVALIDATION_TIMES.PRICE_HISTORY,
),
fetchTokenMarkets(symbol, COINGECKO_REVALIDATION_TIMES.CURRENT_PRICE),
fetchTokenMetadata(
symbol,
COINGECKO_REVALIDATION_TIMES.TOKEN_METADATA,
).catch(() => null),
])
return {
price,
priceHistory,
tokenMarkets,
tokenMetadata,
}
}
export const assetsRouter = router({
all: publicProcedure
.input(
@@ -285,6 +287,7 @@ async function all({
address,
STATUS_NETWORKS[token.chainId],
)
const price = (await fetchTokensPrice([token.symbol]))[token.symbol]
// Skip if price data is not available
@@ -392,6 +395,7 @@ async function all({
const batchSize = 300
const partialAssetEntries = Array.from(partialERC20Assets.entries())
for (let i = 0; i < partialAssetEntries.length; i += batchSize) {
const batch = partialAssetEntries.slice(i, i + batchSize)
const symbols = [...new Set(batch.map(([, asset]) => asset.symbol))]
@@ -424,10 +428,14 @@ async function all({
}
existingAsset.networks.push(asset.networks[0])
existingAsset.balance += asset.balance
existingAsset.total_eur += asset.total_eur
existingAsset.price_eur =
(existingAsset.price_eur + asset.price_eur) / 2
existingAsset.price_percentage_24h_change =
(existingAsset.price_percentage_24h_change +
asset.price_percentage_24h_change) /
@@ -439,6 +447,7 @@ async function all({
)
const existingSymbols = aggregatedAssets.map(a => a.symbol)
const missingSymbols = DEFAULT_TOKEN_SYMBOLS.filter(
s => !existingSymbols.includes(s),
)
@@ -453,6 +462,7 @@ async function all({
const token = erc20TokenList.tokens.find(
t => t.symbol === symbol && t.chainId === 1,
)
if (token && prices[symbol] && prices[symbol].usd !== undefined) {
aggregatedAssets.push({
networks: ['ethereum'],
@@ -525,24 +535,15 @@ async function nativeToken({
throw new Error('Balance not found')
}
const price = (
await fetchTokensPrice(
[token.symbol],
COINGECKO_REVALIDATION_TIMES.CURRENT_PRICE,
)
)[token.symbol]
const priceHistory = await fetchTokenPriceHistory(
token.symbol,
'all',
COINGECKO_REVALIDATION_TIMES.PRICE_HISTORY,
)
const tokenMetadata = await fetchTokenMetadataWithFallback(token.symbol)
const { price, priceHistory, tokenMarkets, tokenMetadata } =
await fetchTokenData(token.symbol)
const asset: Asset = map({
token,
balance,
price,
priceHistory,
tokenMarkets,
tokenMetadata,
})
assets[STATUS_NETWORKS[token.chainId]] = asset
@@ -611,24 +612,15 @@ async function token({
throw new Error(`Balance not found for token ${token.symbol}`)
}
const price = (
await fetchTokensPrice(
[token.symbol],
COINGECKO_REVALIDATION_TIMES.CURRENT_PRICE,
)
)[token.symbol]
const priceHistory = await fetchTokenPriceHistory(
token.symbol,
'all',
COINGECKO_REVALIDATION_TIMES.PRICE_HISTORY,
)
const tokenMetadata = await fetchTokenMetadataWithFallback(token.symbol)
const { price, priceHistory, tokenMarkets, tokenMetadata } =
await fetchTokenData(token.symbol)
const asset = map({
token,
balance: result.tokenBalances[0].tokenBalance,
price,
priceHistory,
tokenMarkets,
tokenMetadata,
})
assets[STATUS_NETWORKS[token.chainId]] = asset
@@ -691,6 +683,8 @@ async function nativeTokenBalanceChart({
days?: '1' | '7' | '30' | '90' | '365' | 'all'
}) {
const currentTime = Math.floor(Date.now() / 1000)
const decimals = 18
const responses = await Promise.all(
networks.map(async network => {
const data = await fetchTokenBalanceHistory(
@@ -699,7 +693,7 @@ async function nativeTokenBalanceChart({
days,
undefined,
currentTime,
18,
decimals,
)
return { [network]: data } as Record<
@@ -750,6 +744,7 @@ async function tokenBalanceChart({
: [erc20Token]
const currentTime = Math.floor(Date.now() / 1000)
const responses = await Promise.all(
filteredERC20Tokens.map(async token => {
const data = await fetchTokenBalanceHistory(
@@ -859,6 +854,44 @@ function aggregateTokenBalanceHistory(
)
}
/**
* Calculate fully diluted market cap
* Fully diluted = price * maxSupply (or totalSupply if maxSupply is not available)
*/
function calculateFullyDiluted(
priceUsd: number,
maxSupply: number | null,
totalSupply: number,
): number {
if (maxSupply && maxSupply > 0) {
return priceUsd * maxSupply
}
if (totalSupply > 0) {
return priceUsd * totalSupply
}
return 0
}
/**
* Extract token description from CoinGecko metadata
* Prefers English description, falls back to any available language
*/
function extractTokenDescription(
tokenMetadata: CoinGeckoCoinDetailResponse | null,
): string {
if (!tokenMetadata?.description) {
return ''
}
return (
tokenMetadata.description['en'] ||
Object.values(tokenMetadata.description)[0] ||
''
)
}
function map(data: {
token:
| (typeof nativeTokenList.tokens)[number]
@@ -866,78 +899,46 @@ function map(data: {
balance: string
price: Awaited<ReturnType<typeof fetchTokensPrice>>[string]
priceHistory: Awaited<ReturnType<typeof fetchTokenPriceHistory>>
tokenMetadata:
| CoinGeckoCoinDetailResponse
| Partial<CoinGeckoCoinDetailResponse>
tokenMarkets: CoinGeckoMarketsResponse[number] | null
tokenMetadata: CoinGeckoCoinDetailResponse | null
}): Asset {
const { token, balance, price, priceHistory, tokenMetadata } = data
const { token, balance, price, priceHistory, tokenMarkets, tokenMetadata } =
data
// CoinGecko price history format: prices is [timestamp, price][]
const prices = priceHistory.prices
.map(([, price]: [number, number]) => price)
.filter((p: number) => p > 0)
// Get supply data from CoinGecko market_data
const metadataTotalSupply =
tokenMetadata?.market_data?.total_supply &&
tokenMetadata.market_data.total_supply > 0
? tokenMetadata.market_data.total_supply
: null
// Get price from /simple/price
const priceUsd = price.usd
const metadataCirculatingSupply =
tokenMetadata?.market_data?.circulating_supply &&
tokenMetadata.market_data.circulating_supply > 0
? tokenMetadata.market_data.circulating_supply
: null
// Get market data from /coins/markets
const maxSupply = tokenMarkets?.max_supply ?? null
const totalSupply = tokenMarkets?.total_supply ?? null
const circulation = tokenMarkets?.circulating_supply ?? null
const volume24 = tokenMarkets?.total_volume ?? null
// Get market cap from CoinGecko market_data
const marketCapFromMetadata =
tokenMetadata?.market_data?.market_cap?.usd &&
tokenMetadata.market_data.market_cap.usd > 0
? tokenMetadata.market_data.market_cap.usd
: null
// Use /simple/price market_cap first (updated every 60s) as it's more real-time
const marketCap = price.usd_market_cap ?? tokenMarkets?.market_cap ?? 0
const rankByMarketCap = tokenMarkets?.market_cap_rank ?? null
const marketCap = marketCapFromMetadata ?? price.usd_market_cap ?? 0
// Use markets data or fallback to calculated values
const finalTotalSupply =
totalSupply ?? (marketCap > 0 && priceUsd > 0 ? marketCap / priceUsd : 0)
// Try to get supply from market cap and price if metadata supply is not available
const supplyFromMarketCap =
marketCap && price.usd && price.usd > 0 ? marketCap / price.usd : null
const finalCirculation =
circulation ?? (marketCap > 0 && priceUsd > 0 ? marketCap / priceUsd : 0)
const totalSupply = metadataTotalSupply || supplyFromMarketCap || 0
const finalVolume24 = volume24 ?? price.usd_24h_vol ?? 0
const circulation = metadataCirculatingSupply || supplyFromMarketCap || 0
const fullyDiluted = calculateFullyDiluted(
priceUsd,
maxSupply,
finalTotalSupply,
)
// Fully diluted = price * total supply (or max supply if available)
const maxSupply =
tokenMetadata?.market_data?.max_supply &&
tokenMetadata.market_data.max_supply > 0
? tokenMetadata.market_data.max_supply
: null
const fullyDiluted =
maxSupply && maxSupply > 0
? price.usd * maxSupply
: metadataTotalSupply && metadataTotalSupply > 0
? price.usd * metadataTotalSupply
: totalSupply > 0
? price.usd * totalSupply
: 0
// market_cap_rank can be 0 (valid rank), so we only use fallback for null/undefined
const rankByMarketCap =
tokenMetadata?.market_cap_rank ??
tokenMetadata?.market_data?.market_cap_rank ??
null
// Get description from CoinGecko description object
const description =
tokenMetadata?.description?.['en'] ||
(tokenMetadata?.description
? Object.values(tokenMetadata.description)[0]
: '') ||
''
const about = description
const about = extractTokenDescription(tokenMetadata)
return {
networks: [STATUS_NETWORKS[token.chainId]],
@@ -954,12 +955,12 @@ function map(data: {
decimals: token.decimals,
metadata: {
market_cap: marketCap,
fully_dilluted: fullyDiluted,
circulation: circulation,
total_supply: totalSupply,
all_time_high: prices.length ? Math.max(...prices) : price.usd,
all_time_low: prices.length ? Math.min(...prices) : price.usd,
volume_24: price.usd_24h_vol ?? 0,
fully_diluted: fullyDiluted,
circulation: finalCirculation,
total_supply: finalTotalSupply,
all_time_high: prices.length ? Math.max(...prices) : priceUsd,
all_time_low: prices.length ? Math.min(...prices) : priceUsd,
volume_24: finalVolume24,
rank_by_market_cap: rankByMarketCap,
about: about,
},
@@ -968,7 +969,9 @@ function map(data: {
function sum(assets: Asset[] | Omit<Asset, 'metadata'>[]) {
const total_balance = assets.reduce((acc, asset) => acc + asset.balance, 0)
const total_eur = assets.reduce((acc, asset) => acc + asset.total_eur, 0)
const total_eur_24h_change = assets.reduce(
(acc, asset) =>
acc + asset.total_eur * (asset.price_percentage_24h_change / 100),
@@ -18,6 +18,7 @@ import type {
CoinGeckoCoinHistoryResponse,
CoinGeckoCoinListResponse,
CoinGeckoMarketChartResponse,
CoinGeckoMarketsResponse,
CoinGeckoNFTFloorPriceResponse,
CoinGeckoSimplePriceResponse,
} from './types'
@@ -435,6 +436,42 @@ export async function fetchTokensPrice(
}
}
/**
* @see https://docs.coingecko.com/reference/coins-markets
*
* Fetches market data for tokens using CoinGecko API.
*/
export async function fetchTokenMarkets(
symbol: string,
revalidate: Revalidation = COINGECKO_REVALIDATION_TIMES.TOKEN_METADATA,
): Promise<CoinGeckoMarketsResponse[number] | null> {
try {
const coinId = await getCoinIdFromSymbol(symbol)
if (!coinId) {
throw new Error(`Coin not found for symbol: ${symbol}`)
}
const url = new URL(`${PROXY_BASE_URL}/v1/coins/markets`)
url.searchParams.set('ids', coinId)
url.searchParams.set('vs_currency', 'usd')
url.searchParams.set('order', 'market_cap_desc')
url.searchParams.set('per_page', '1')
url.searchParams.set('page', '1')
url.searchParams.set('sparkline', 'false')
const marketsData = await _fetchWithAuth<CoinGeckoMarketsResponse>(
url,
revalidate,
'fetchTokenMarkets',
)
return marketsData[0] || null
} catch (error: unknown) {
console.error(`Failed to fetch markets data for ${symbol}:`, error)
return null
}
}
/**
* Returns the price of tokens at a specific timestamp.
* @see https://docs.coingecko.com/reference/coins-id-history
@@ -121,6 +121,38 @@ export type CoinGeckoCoinHistoryResponse = {
localization: Record<string, string>
}
/**
* Markets Response
* @see https://docs.coingecko.com/reference/coins-markets
*/
export type CoinGeckoMarketsResponse = Array<{
id: string
symbol: string
name: string
image: string
current_price: number
market_cap: number
market_cap_rank: number | null
fully_diluted_valuation: number | null
total_volume: number
high_24h: number | null
low_24h: number | null
price_change_24h: number | null
price_change_percentage_24h: number | null
market_cap_change_24h: number | null
market_cap_change_percentage_24h: number | null
circulating_supply: number | null
total_supply: number | null
max_supply: number | null
ath: number | null
ath_change_percentage: number | null
ath_date: string | null
atl: number | null
atl_change_percentage: number | null
atl_date: string | null
last_updated: string
}>
/**
* NFT Floor Price Response
* @see https://docs.coingecko.com/v3.0.1/reference/nfts-contract-address
+1137 -1194
View File
File diff suppressed because it is too large Load Diff