Implement proxies (#860)

* Refactor API calls to use Status RPC for asset management

* Refactor API integration to utilize market-proxy service for token data retrieval

* Update environment variables to replace STATUS_RPC with ETH_RPC for proxy authentication

* Remove CryptoCompare API keys and references across multiple files, updating to use market-proxy service for token data retrieval.

* Add changeset for CRYPTOCOMPARE to MARKET_PROXY refactor

* Update ETH_RPC_PROXY authentication to make username and password optional

* Refactor proxy authentication to use server environment variables for credentials in assets and market-proxy services

* Refactor asset metadata fetching to use fallback mechanism and streamline token filtering by networks

* Enhance token metadata handling by implementing fallback

* Update ETH_RPC_PROXY authentication to require username and password in environment configuration

* Add environment variables

* Refactor nullish coalescing

* Remove unnecessary files

* Refactor token ID mapping in market-proxy

* Refactor market-proxy to introduce default values and improve token metadata handling

* Enhance token ID retrieval in market-proxy by integrating ERC20 token list and improving coin list caching mechanism

* Refactor market-proxy to utilize CoinGecko API responses directly, enhancing price

* Refactor asset and market-proxy modules

* Refactor to replace market-proxy with CoinGecko API integration

* Refactor CoinGecko API service to exclusively use proxy endpoint

* add ETH_RPC_PROXY_URL and MARKET_PROXY_URL
This commit is contained in:
Jinho Jang
2025-12-05 10:47:23 +09:00
committed by GitHub
parent f50d31d2f6
commit d92da0c61b
20 changed files with 1464 additions and 1775 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@status-im/wallet': patch
---
Refactor: Replace CRYPTOCOMPARE references with MARKET_PROXY
+6
View File
@@ -20,3 +20,9 @@ ALCHEMY_API_KEYS=""
COINGECKO_API_KEY=""
CRYPTOCOMPARE_API_KEYS=""
MERCURYO_SECRET_KEY=""
ETH_RPC_PROXY_URL=""
ETH_RPC_PROXY_AUTH_USERNAME=""
ETH_RPC_PROXY_AUTH_PASSWORD=""
MARKET_PROXY_URL=""
MARKET_PROXY_AUTH_USERNAME=""
MARKET_PROXY_AUTH_PASSWORD=""
+6 -1
View File
@@ -20,7 +20,12 @@ export const envSchema = z.object({
MERCURYO_SECRET_KEY: z.string(),
ALCHEMY_API_KEYS: z.string(),
COINGECKO_API_KEY: z.string(),
CRYPTOCOMPARE_API_KEYS: z.string(),
ETH_RPC_PROXY_URL: z.string(),
ETH_RPC_PROXY_AUTH_USERNAME: z.string(),
ETH_RPC_PROXY_AUTH_PASSWORD: z.string(),
MARKET_PROXY_URL: z.string(),
MARKET_PROXY_AUTH_USERNAME: z.string(),
MARKET_PROXY_AUTH_PASSWORD: z.string(),
})
const result = envSchema.safeParse(process.env)
+6
View File
@@ -24,3 +24,9 @@ CRYPTOCOMPARE_API_KEYS=""
NEXT_PUBLIC_FEATURE_FLAG_WATCHED_ADDRESSES="true"
#VERCEL_WEB_ANALYTICS_DISABLE_LOGS="true"
#NEXT_PRIVATE_DEBUG_CACHE=1
ETH_RPC_PROXY_URL=""
ETH_RPC_PROXY_AUTH_USERNAME=""
ETH_RPC_PROXY_AUTH_PASSWORD=""
MARKET_PROXY_URL=""
MARKET_PROXY_AUTH_USERNAME=""
MARKET_PROXY_AUTH_PASSWORD=""
@@ -265,7 +265,7 @@ async function Token({
},
{
label: 'Rank by Mcap',
value: metadata?.rank_by_market_cap,
value: metadata?.rank_by_market_cap ?? 'N/A',
},
].map((item, index) => (
<div
+6 -1
View File
@@ -19,9 +19,14 @@ export const envSchema = z.object({
INFURA_API_KEY: z.string(),
ALCHEMY_API_KEYS: z.string(),
COINGECKO_API_KEY: z.string(),
CRYPTOCOMPARE_API_KEYS: z.string(),
PORT: z.coerce.number().optional(),
MERCURYO_SECRET_KEY: z.string(),
ETH_RPC_PROXY_URL: z.string(),
ETH_RPC_PROXY_AUTH_USERNAME: z.string(),
ETH_RPC_PROXY_AUTH_PASSWORD: z.string(),
MARKET_PROXY_URL: z.string(),
MARKET_PROXY_AUTH_USERNAME: z.string(),
MARKET_PROXY_AUTH_PASSWORD: z.string(),
})
const result = envSchema.safeParse(process.env)
@@ -171,11 +171,6 @@ If a user or service exceeds a predefined request limit, the proxy server can th
The following personal data is processed by the Status Software proxy server.
📈 [**Cryptocompare**](https://www.cryptocompare.com/)<ExternalIcon />
- **Your IP address (Logged for 15 days)**. As part of sending an HTTPS API call, your IP address will be shared with our proxy server.
- **What tokens you are interested in (Passed-Through)**. As part of the HTTPS API calls we will see what tokens you want price data for.
✍ **[Infura](https://www.infura.io/)<ExternalIcon /> & [Grove](https://www.grove.city/)<ExternalIcon />**
- **Your IP address (Logged for 15 days)**. As part of sending an HTTPS API call your IP address will be shared with our proxy server.
@@ -82,12 +82,6 @@ Status Software only shares the above personal data with one of the below servic
### Providers
#### Cryptocompare
- [https://www.cryptocompare.com/](https://www.cryptocompare.com/)
- [https://min-api.cryptocompare.com/documentation](https://min-api.cryptocompare.com/documentation)
- [https://www.cryptocompare.com/privacy-policy/](https://www.cryptocompare.com/privacy-policy/)
#### Coingecko
- [https://www.coingecko.com/](https://www.coingecko.com/)
@@ -302,6 +302,7 @@ const Token = (props: Props) => {
const tokenAssets = Object.values(tokenDetail.assets)
if (tokenAssets.length > 0 && tokenAssets[0]) {
const metadata = tokenAssets[0].metadata
const content = await renderMarkdown(
metadata?.about || 'No description available.',
)
@@ -735,7 +736,7 @@ const Token = (props: Props) => {
},
{
label: 'Rank by Mcap',
value: metadata?.rank_by_market_cap,
value: metadata?.rank_by_market_cap ?? 'N/A',
},
].map((item, index) => (
<div
-1
View File
@@ -51,7 +51,6 @@ export default defineConfig({
'process.env': {
ALCHEMY_API_KEYS: 'test',
INFURA_API_KEY: 'test',
CRYPTOCOMPARE_API_KEYS: 'test',
COINGECKO_API_KEY: 'test',
MERCURYO_SECRET_KEY: 'test',
VERCEL: 'test',
+6 -2
View File
@@ -2,6 +2,10 @@ INFURA_API_KEY=""
ALCHEMY_API_KEY=""
ALCHEMY_API_KEYS=""
COINGECKO_API_KEY=""
CRYPTOCOMPARE_API_KEY=""
CRYPTOCOMPARE_API_KEYS=""
MERCURYO_SECRET_KEY=""
ETH_RPC_PROXY_AUTH_USERNAME=""
ETH_RPC_PROXY_AUTH_PASSWORD=""
MARKET_PROXY_AUTH_USERNAME=""
MARKET_PROXY_AUTH_PASSWORD=""
ETH_RPC_PROXY_URL=""
MARKET_PROXY_URL=""
+6 -1
View File
@@ -11,9 +11,14 @@ export const envSchema = z.object({
INFURA_API_KEY: z.string(),
ALCHEMY_API_KEYS: z.string(),
COINGECKO_API_KEY: z.string(),
CRYPTOCOMPARE_API_KEYS: z.string(),
PORT: z.coerce.number().optional(),
MERCURYO_SECRET_KEY: z.string(),
ETH_RPC_PROXY_URL: z.string(),
ETH_RPC_PROXY_AUTH_USERNAME: z.string(),
ETH_RPC_PROXY_AUTH_PASSWORD: z.string(),
MARKET_PROXY_URL: z.string(),
MARKET_PROXY_AUTH_USERNAME: z.string(),
MARKET_PROXY_AUTH_PASSWORD: z.string(),
})
const result = envSchema.safeParse(process.env)
@@ -6,7 +6,7 @@ import { z } from 'zod'
import erc20TokenList from '../../../constants/erc20.json'
import { groupBy } from '../../../utils/group-by'
import { getAssetTransfers, getTransactionStatus } from '../../services/alchemy'
import { fetchTokensPriceForDate } from '../../services/cryptocompare'
import { fetchTokensPriceForDate } from '../../services/coingecko/index'
import { publicProcedure, router } from '../lib/trpc'
import type { AssetTransfer } from '../../services/alchemy/types'
@@ -41,7 +41,7 @@ const cachedPriceData = cache(async (key: string) => {
return await fetchTokensPriceForDate(symbols, timestamp)
})
type PriceDataResponse = Record<string, { USD?: { PRICE: number } }>
type PriceDataResponse = Record<string, { usd: number }>
const batchPriceRequests = async (
priceRequests: Array<{ symbols: string[]; timestamp: number }>,
@@ -242,7 +242,7 @@ export async function page({
activity.asset || (activity.category === 'external' ? 'ETH' : null)
if (assetSymbol && priceData[assetSymbol]) {
const eurRate = priceData[assetSymbol].USD?.PRICE ?? 0
const eurRate = priceData[assetSymbol]?.usd ?? 0
return {
...activity,
@@ -346,7 +346,7 @@ export async function activity(
activity.asset || (activity.category === 'external' ? 'ETH' : null)
if (assetSymbol && priceData[assetSymbol]) {
const eurRate = priceData[assetSymbol].USD?.PRICE ?? 0
const eurRate = priceData[assetSymbol]?.usd ?? 0
return {
...activity,
+273 -206
View File
@@ -11,13 +11,14 @@ import {
getNativeTokenBalance,
} from '../../services/alchemy'
import {
CRYPTOCOMPARE_REVALIDATION_TIMES,
COINGECKO_REVALIDATION_TIMES,
fetchTokenMetadata,
legacy_fetchTokenPriceHistory,
legacy_fetchTokensPrice,
} from '../../services/cryptocompare'
fetchTokenPriceHistory,
fetchTokensPrice,
} from '../../services/coingecko/index'
import { publicProcedure, router } from '../lib/trpc'
import type { CoinGeckoCoinDetailResponse } from '../../services/coingecko/types'
import type { NetworkType } from '../types'
type ERC20Token = (typeof erc20TokenList)['tokens'][number]
@@ -39,7 +40,7 @@ type Asset = {
total_supply: number
all_time_high: number
all_time_low: number
rank_by_market_cap: number
rank_by_market_cap: number | null
about: string
volume_24: number
// links: Record<
@@ -62,7 +63,7 @@ const STATUS_NETWORKS: Record<number, NetworkType> = {
1: 'ethereum',
}
const DEFAULT_TOKEN_SYMBOLS = [
export const DEFAULT_TOKEN_SYMBOLS = [
'ETH',
'SNT',
'USDC',
@@ -71,7 +72,89 @@ const DEFAULT_TOKEN_SYMBOLS = [
'PEPE',
'UNI',
'SHIB',
]
] as const
export type DefaultTokenSymbol = (typeof DEFAULT_TOKEN_SYMBOLS)[number]
export const DEFAULT_TOKEN_IDS: Record<string, string> = {
ETH: 'ethereum',
SNT: 'status',
USDC: 'usd-coin',
USDT: 'tether',
LINK: 'chainlink',
PEPE: 'pepe',
UNI: 'uniswap',
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,
defaultName: string,
defaultSymbol: string,
) {
const summary = sum(assets)
const firstAsset = assets[0]
return {
...summary,
icon: defaultIcon,
name: defaultName,
symbol: defaultSymbol,
about: firstAsset?.metadata.about ?? '',
contracts: assets.reduce(
(acc, asset) => {
if (asset.contract) {
acc[asset.networks[0]] = asset.contract
}
return acc
},
{} as Record<NetworkType, string>,
),
}
}
function filterTokensByNetworks<T extends { chainId: number }>(
tokens: T[],
networks: NetworkType[],
): T[] {
return tokens.filter(token =>
Object.entries(STATUS_NETWORKS)
.filter(([, network]) => networks.includes(network))
.map(([chainId]) => Number(chainId))
.includes(token.chainId),
)
}
export const assetsRouter = router({
all: publicProcedure
@@ -174,14 +257,6 @@ export const assetsRouter = router({
}),
})
// const cachedAll = cache(async (address: string, networks: NetworkType[]) => {
// return await all({ address, networks })
// })
// const cachedAll = cache((address: string, networks: NetworkType[]) => {
// return all({ address, networks })
// })
const cachedAll = cache(async (key: string) => {
const { address, networks } = JSON.parse(key)
@@ -195,30 +270,30 @@ async function all({
address: string
networks: NetworkType[]
}) {
// await cachedDelay()
// console.log(' DELAY all()')
// await delay()
try {
const assets: Omit<Asset, 'metadata'>[] = []
// note: https://docs.alchemy.com/reference/alchemy-gettokenbalances can now return native tokens together with ERC20 tokens
// Native tokens
const filteredNativeTokens = nativeTokenList.tokens.filter(token =>
Object.entries(STATUS_NETWORKS)
.filter(([, network]) => networks.includes(network))
.map(([chainId]) => Number(chainId))
.includes(token.chainId),
const filteredNativeTokens = filterTokensByNetworks(
nativeTokenList.tokens,
networks,
)
await Promise.all(
filteredNativeTokens.map(async token => {
const balance = await getNativeTokenBalance(
address,
STATUS_NETWORKS[token.chainId],
)
const price = (await legacy_fetchTokensPrice([token.symbol]))[
token.symbol
]
const price = (await fetchTokensPrice([token.symbol]))[token.symbol]
// Skip if price data is not available
if (!price || price.usd === undefined) {
console.warn(
`Price data not available for token ${token.symbol}, skipping`,
)
return
}
const asset: Omit<Asset, 'metadata'> = {
networks: [STATUS_NETWORKS[token.chainId]],
@@ -227,11 +302,10 @@ async function all({
icon: token.logoURI,
name: token.name,
symbol: token.symbol,
price_eur: price.USD['PRICE'],
price_percentage_24h_change: price.USD['CHANGEPCT24HOUR'],
price_eur: price.usd,
price_percentage_24h_change: price.usd_24h_change ?? 0,
balance: Number(balance) / 10 ** token.decimals,
total_eur:
(Number(balance) / 10 ** token.decimals) * price.USD['PRICE'],
total_eur: (Number(balance) / 10 ** token.decimals) * price.usd,
decimals: token.decimals,
}
@@ -241,14 +315,10 @@ async function all({
// ERC20 tokens
const filteredERC20Tokens = new Map(
erc20TokenList.tokens
.filter(token =>
Object.entries(STATUS_NETWORKS)
.filter(([, network]) => networks.includes(network))
.map(([chainId]) => Number(chainId))
.includes(token.chainId),
)
.map(token => [toChecksumAddress(token.address), token]),
filterTokensByNetworks(erc20TokenList.tokens, networks).map(token => [
toChecksumAddress(token.address),
token,
]),
)
const ERC20TokensByNetwork = Array.from(
@@ -276,41 +346,48 @@ async function all({
>) {
let pageKey: string | undefined
do {
const result = await getERC20TokensBalance(
address,
network as NetworkType,
undefined,
pageKey,
)
try {
do {
const result = await getERC20TokensBalance(
address,
network as NetworkType,
undefined,
pageKey,
)
for (const balance of result.tokenBalances) {
const tokenBalance = Number(balance.tokenBalance)
for (const balance of result.tokenBalances) {
const tokenBalance = Number(balance.tokenBalance)
if (tokenBalance > 0) {
const token = filteredERC20Tokens.get(
toChecksumAddress(balance.contractAddress),
)
if (token) {
const checksummedAddress = toChecksumAddress(
balance.contractAddress,
if (tokenBalance > 0) {
const token = filteredERC20Tokens.get(
toChecksumAddress(balance.contractAddress),
)
partialERC20Assets.set(checksummedAddress, {
networks: [network as NetworkType],
native: false,
contract: checksummedAddress,
icon: token.logoURI,
name: token.name,
symbol: token.symbol,
balance: tokenBalance / 10 ** token.decimals,
decimals: token.decimals,
})
if (token) {
const checksummedAddress = toChecksumAddress(
balance.contractAddress,
)
partialERC20Assets.set(checksummedAddress, {
networks: [network as NetworkType],
native: false,
contract: checksummedAddress,
icon: token.logoURI,
name: token.name,
symbol: token.symbol,
balance: tokenBalance / 10 ** token.decimals,
decimals: token.decimals,
})
}
}
}
}
pageKey = result.pageKey
} while (pageKey)
pageKey = result.pageKey
} while (pageKey)
} catch (error) {
console.error(
`[assets.all] Failed to fetch ERC20 token balances for ${network}:`,
error,
)
}
}
const batchSize = 300
@@ -319,18 +396,18 @@ async function all({
const batch = partialAssetEntries.slice(i, i + batchSize)
const symbols = [...new Set(batch.map(([, asset]) => asset.symbol))]
const prices = await legacy_fetchTokensPrice(symbols)
const prices = await fetchTokensPrice(symbols)
for (const [, partialAsset] of batch) {
const price = prices[partialAsset.symbol]
if (price) {
if (price && price.usd !== undefined) {
const asset: Omit<Asset, 'metadata'> = {
...partialAsset,
price_eur: price['USD']['PRICE'] ?? 0,
price_percentage_24h_change: price['USD']['CHANGEPCT24HOUR'] ?? 0,
total_eur: partialAsset.balance * (price['USD']['PRICE'] ?? 0),
decimals: partialAsset.decimals, // <-- Mantém decimals
price_eur: price.usd,
price_percentage_24h_change: price.usd_24h_change ?? 0,
total_eur: partialAsset.balance * price.usd,
decimals: partialAsset.decimals,
}
assets.push(asset)
@@ -367,16 +444,16 @@ async function all({
)
if (missingSymbols.length > 0) {
const prices = await legacy_fetchTokensPrice(
const prices = await fetchTokensPrice(
missingSymbols,
CRYPTOCOMPARE_REVALIDATION_TIMES.CURRENT_PRICE,
COINGECKO_REVALIDATION_TIMES.CURRENT_PRICE,
)
for (const symbol of missingSymbols) {
const token = erc20TokenList.tokens.find(
t => t.symbol === symbol && t.chainId === 1,
)
if (token && prices[symbol]) {
if (token && prices[symbol] && prices[symbol].usd !== undefined) {
aggregatedAssets.push({
networks: ['ethereum'],
native: false,
@@ -384,8 +461,8 @@ async function all({
icon: token.logoURI,
name: token.name,
symbol: token.symbol,
price_eur: prices[symbol].USD.PRICE,
price_percentage_24h_change: prices[symbol].USD.CHANGEPCT24HOUR,
price_eur: prices[symbol].usd,
price_percentage_24h_change: prices[symbol].usd_24h_change ?? 0,
balance: 0,
total_eur: 0,
decimals: token.decimals,
@@ -425,19 +502,10 @@ async function nativeToken({
networks: NetworkType[]
symbol: string
}) {
// console.log(' DELAY nativeToken()')
// await delay()
const filteredNativeTokens = nativeTokenList.tokens.filter(token => {
if (token.symbol !== symbol) {
return false
}
return Object.entries(STATUS_NETWORKS)
.filter(([, network]) => networks.includes(network))
.map(([chainId]) => Number(chainId))
.includes(token.chainId)
})
const filteredNativeTokens = filterTokensByNetworks(
nativeTokenList.tokens,
networks,
).filter(token => token.symbol === symbol)
if (!filteredNativeTokens.length) {
throw new Error('Token not found')
@@ -458,20 +526,17 @@ async function nativeToken({
}
const price = (
await legacy_fetchTokensPrice(
await fetchTokensPrice(
[token.symbol],
CRYPTOCOMPARE_REVALIDATION_TIMES.CURRENT_PRICE,
COINGECKO_REVALIDATION_TIMES.CURRENT_PRICE,
)
)[token.symbol]
const priceHistory = await legacy_fetchTokenPriceHistory(
const priceHistory = await fetchTokenPriceHistory(
token.symbol,
'all',
CRYPTOCOMPARE_REVALIDATION_TIMES.PRICE_HISTORY,
)
const tokenMetadata = await fetchTokenMetadata(
token.symbol,
CRYPTOCOMPARE_REVALIDATION_TIMES.TOKEN_METADATA,
COINGECKO_REVALIDATION_TIMES.PRICE_HISTORY,
)
const tokenMetadata = await fetchTokenMetadataWithFallback(token.symbol)
const asset: Asset = map({
token,
@@ -484,25 +549,15 @@ async function nativeToken({
}),
)
const summary = sum(Object.values(assets).flat())
const assetsArray = Object.values(assets).flat()
return {
summary: {
...summary,
icon: filteredNativeTokens[0].logoURI,
name: filteredNativeTokens[0].name,
symbol: filteredNativeTokens[0].symbol,
about: Object.values(assets)[0]?.metadata.about ?? '',
contracts: Object.values(assets).reduce(
(acc, asset) => {
if (asset.contract) {
acc[asset.networks[0]] = asset.contract
}
return acc
},
{} as Record<NetworkType, string>,
),
},
summary: buildTokenSummary(
assetsArray,
filteredNativeTokens[0].logoURI,
filteredNativeTokens[0].name,
filteredNativeTokens[0].symbol,
),
assets,
}
}
@@ -522,9 +577,6 @@ async function token({
networks: NetworkType[]
contract: string
}) {
// console.log(' DELAY token()')
// await delay()
const erc20Token = erc20TokenList.tokens.find(
token =>
token.address === contract &&
@@ -560,20 +612,17 @@ async function token({
}
const price = (
await legacy_fetchTokensPrice(
await fetchTokensPrice(
[token.symbol],
CRYPTOCOMPARE_REVALIDATION_TIMES.CURRENT_PRICE,
COINGECKO_REVALIDATION_TIMES.CURRENT_PRICE,
)
)[token.symbol]
const priceHistory = await legacy_fetchTokenPriceHistory(
const priceHistory = await fetchTokenPriceHistory(
token.symbol,
'all',
CRYPTOCOMPARE_REVALIDATION_TIMES.PRICE_HISTORY,
)
const tokenMetadata = await fetchTokenMetadata(
token.symbol,
CRYPTOCOMPARE_REVALIDATION_TIMES.TOKEN_METADATA,
COINGECKO_REVALIDATION_TIMES.PRICE_HISTORY,
)
const tokenMetadata = await fetchTokenMetadataWithFallback(token.symbol)
const asset = map({
token,
@@ -586,25 +635,15 @@ async function token({
}),
)
const summary = sum(Object.values(assets).flat())
const assetsArray = Object.values(assets).flat()
return {
summary: {
...summary,
icon: erc20Token.logoURI,
name: erc20Token.name,
symbol: erc20Token.symbol,
about: Object.values(assets)[0]?.metadata.about ?? '',
contracts: Object.values(assets).reduce(
(acc, asset) => {
if (asset.contract) {
acc[asset.networks[0]] = asset.contract
}
return acc
},
{} as Record<NetworkType, string>,
),
},
summary: buildTokenSummary(
assetsArray,
erc20Token.logoURI,
erc20Token.name,
erc20Token.symbol,
),
assets,
}
}
@@ -622,45 +661,20 @@ async function tokenPriceChart({
symbol: string
days?: '1' | '7' | '30' | '90' | '365' | 'all'
}) {
// console.log(' DELAY tokenPriceChart()')
// await delay()
const data = await fetchTokenPriceHistory(symbol, days)
const data = await legacy_fetchTokenPriceHistory(symbol, days)
const mappedData = data.map(({ time, close }) => ({
date: new Date(time * 1000).toISOString(),
price: close,
return data.prices.map(([timestamp, price]: [number, number]) => ({
date: new Date(timestamp).toISOString(),
price: price,
}))
return mappedData
}
const cachedNativeTokenPriceChart = cache(async (key: string) => {
const { symbol, days } = JSON.parse(key)
return await nativeTokenPriceChart({ symbol, days })
return await tokenPriceChart({ symbol, days })
})
async function nativeTokenPriceChart({
symbol,
days,
}: {
symbol: string
days?: '1' | '7' | '30' | '90' | '365' | 'all'
}) {
// console.log(' DELAY nativeTokenPriceChart()')
// await delay()
const data = await legacy_fetchTokenPriceHistory(symbol, days)
const mappedData = data.map(({ time, close }) => ({
date: new Date(time * 1000).toISOString(),
price: close,
}))
return mappedData
}
const cachedNativeTokenBalanceChart = cache(async (key: string) => {
const { address, networks, days } = JSON.parse(key)
@@ -676,9 +690,6 @@ async function nativeTokenBalanceChart({
networks: NetworkType[]
days?: '1' | '7' | '30' | '90' | '365' | 'all'
}) {
// console.log(' DELAY nativeTokenBalanceChart()')
// await delay()
const currentTime = Math.floor(Date.now() / 1000)
const responses = await Promise.all(
networks.map(async network => {
@@ -722,9 +733,6 @@ async function tokenBalanceChart({
contract: string
days?: '1' | '7' | '30' | '90' | '365' | 'all'
}) {
// console.log(' DELAY tokenBalanceChart()')
// await delay()
const erc20Token = erc20TokenList.tokens.find(
token =>
token.address === contract &&
@@ -856,14 +864,80 @@ function map(data: {
| (typeof nativeTokenList.tokens)[number]
| (typeof erc20TokenList.tokens)[number]
balance: string
price: Awaited<ReturnType<typeof legacy_fetchTokensPrice>>[string]
priceHistory: Awaited<ReturnType<typeof legacy_fetchTokenPriceHistory>>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tokenMetadata: any
price: Awaited<ReturnType<typeof fetchTokensPrice>>[string]
priceHistory: Awaited<ReturnType<typeof fetchTokenPriceHistory>>
tokenMetadata:
| CoinGeckoCoinDetailResponse
| Partial<CoinGeckoCoinDetailResponse>
}): Asset {
const { token, balance, price, priceHistory, tokenMetadata } = data
const lows = priceHistory.map(({ low }) => low).filter(low => low > 0)
// 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
const metadataCirculatingSupply =
tokenMetadata?.market_data?.circulating_supply &&
tokenMetadata.market_data.circulating_supply > 0
? tokenMetadata.market_data.circulating_supply
: 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
const marketCap = marketCapFromMetadata ?? price.usd_market_cap ?? 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 totalSupply = metadataTotalSupply || supplyFromMarketCap || 0
const circulation = metadataCirculatingSupply || supplyFromMarketCap || 0
// 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
return {
networks: [STATUS_NETWORKS[token.chainId]],
@@ -873,22 +947,21 @@ function map(data: {
icon: token.logoURI,
name: token.name,
symbol: token.symbol,
price_eur: price.USD['PRICE'],
price_percentage_24h_change: price.USD['CHANGEPCT24HOUR'],
price_eur: price.usd,
price_percentage_24h_change: price.usd_24h_change ?? 0,
balance: Number(balance) / 10 ** token.decimals,
total_eur: (Number(balance) / 10 ** token.decimals) * price.USD['PRICE'],
total_eur: (Number(balance) / 10 ** token.decimals) * price.usd,
decimals: token.decimals,
metadata: {
market_cap: price.USD['MKTCAP'],
fully_dilluted: price.USD['PRICE'] * tokenMetadata['SUPPLY_TOTAL'],
circulation: price.USD['CIRCULATINGSUPPLY'],
total_supply: tokenMetadata['SUPPLY_TOTAL'],
all_time_high: Math.max(...priceHistory.map(({ high }) => high)),
all_time_low: lows.length ? Math.min(...lows) : price.USD['PRICE'],
volume_24: price.USD['TOTALVOLUME24HTO'],
rank_by_market_cap:
tokenMetadata['TOPLIST_BASE_RANK']['TOTAL_MKT_CAP_USD'],
about: tokenMetadata['ASSET_DESCRIPTION'],
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,
rank_by_market_cap: rankByMarketCap,
about: about,
},
}
}
@@ -912,9 +985,3 @@ function sum(assets: Asset[] | Omit<Asset, 'metadata'>[]) {
total_percentage_24h_change,
}
}
// async function delay() {
// await new Promise(resolve => setTimeout(resolve, 3 * 1000))
// }
// const cachedDelay = cache(wait)
@@ -8,9 +8,9 @@ import {
getNFTs,
} from '../../services/alchemy'
import {
CRYPTOCOMPARE_REVALIDATION_TIMES,
legacy_fetchTokensPrice,
} from '../../services/cryptocompare'
COINGECKO_REVALIDATION_TIMES,
fetchTokensPrice,
} from '../../services/coingecko/index'
import { publicProcedure, router } from '../lib/trpc'
import type {
@@ -170,14 +170,14 @@ async function collectible(
const currency = 'USD'
let price: number | undefined
let floorPrice: number | undefined
if (network === 'ethereum') {
const symbol = 'ETH'
price = (
await legacy_fetchTokensPrice(
[symbol],
CRYPTOCOMPARE_REVALIDATION_TIMES.CURRENT_PRICE,
)
)[symbol][currency].PRICE
const priceData = await fetchTokensPrice(
[symbol],
COINGECKO_REVALIDATION_TIMES.CURRENT_PRICE,
)
price = priceData[symbol]?.usd
floorPrice = nft.contract.openSeaMetadata.floorPrice ?? undefined
// note: looksRare data is significantly inconsistent with opensea data
// floorPrice = (await getNFTFloorPrice(contract, network)).openSea.floorPrice
+292 -177
View File
@@ -12,14 +12,13 @@ import { formatEther } from 'ethers'
import { serverEnv } from '../../../config/env.server.mjs'
import {
getRandomApiKey,
markApiKeyAsRateLimited,
markApiKeyAsSuccessful,
} from '../api-key-rotation'
import {
CRYPTOCOMPARE_REVALIDATION_TIMES,
legacy_fetchTokensPrice,
} from '../cryptocompare'
COINGECKO_REVALIDATION_TIMES,
fetchTokensPrice,
} from '../coingecko/index'
import { estimateConfirmationTime, processFeeHistory } from './utils'
import type { NetworkType } from '../../api/types'
@@ -57,6 +56,15 @@ const alchemyNetworks = {
bsc: 'bnb-mainnet',
}
const statusRpcNetworks = {
ethereum: { chain: 'ethereum', network: 'mainnet' },
optimism: { chain: 'optimism', network: 'mainnet' },
arbitrum: { chain: 'arbitrum', network: 'mainnet' },
base: { chain: 'base', network: 'mainnet' },
polygon: { chain: 'polygon', network: 'mainnet' },
bsc: { chain: 'bsc', network: 'mainnet' },
}
const unsupportedCategoriesByNetwork: Partial<Record<NetworkType, string[]>> = {
// bsc: ['internal'],
// arbitrum: ['internal'],
@@ -73,6 +81,18 @@ const allCategories = [
'specialnft',
] as const
const STATUS_ETH_RPC_PROXY = serverEnv.ETH_RPC_PROXY_URL
const STATUS_RPC_AUTH: { username: string; password: string } = {
username: serverEnv.ETH_RPC_PROXY_AUTH_USERNAME,
password: serverEnv.ETH_RPC_PROXY_AUTH_PASSWORD,
}
function getStatusRpcUrl(network: NetworkType): string {
const { chain, network: networkName } = statusRpcNetworks[network]
return `${STATUS_ETH_RPC_PROXY}/${chain}/${networkName}/alchemy`
}
// todo: use `genesisTimestamp` for `all` days parame
// const networkConfigs = {
// ethereum: {
@@ -106,15 +126,20 @@ export async function getNativeTokenBalance(
network: NetworkType,
) {
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
const url = new URL(getStatusRpcUrl(network))
return _fetch<NativeTokenBalanceResponseBody>(
url,
'POST',
0,
{
id: 1,
jsonrpc: '2.0',
method: 'eth_getBalance',
params: [address, 'latest'],
},
STATUS_RPC_AUTH,
)
return _fetch<NativeTokenBalanceResponseBody>(url, 'POST', 0, {
id: 1,
jsonrpc: '2.0',
method: 'eth_getBalance',
params: [address, 'latest'],
})
})
const balance = body.result
@@ -145,16 +170,36 @@ export async function getERC20TokensBalance(
}
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
const url = new URL(getStatusRpcUrl(network))
return _fetch<ERC20TokenBalanceResponseBody>(
url,
'POST',
0,
{
jsonrpc: '2.0',
method: 'alchemy_getTokenBalances',
params,
},
STATUS_RPC_AUTH,
)
return _fetch<ERC20TokenBalanceResponseBody>(url, 'POST', 0, {
jsonrpc: '2.0',
method: 'alchemy_getTokenBalances',
params,
})
})
if (
typeof body === 'object' &&
body !== null &&
'error' in body &&
body.error
) {
const error = body.error as { code?: number; message?: string }
throw new Error(`Alchemy API error: ${error.message || 'Unknown error'}`, {
cause: error.code,
})
}
if (!body.result) {
throw new Error('Alchemy API response missing result field')
}
return {
tokenBalances: body.result.tokenBalances,
pageKey: body.result.pageKey,
@@ -172,21 +217,26 @@ export async function getTokensBalance(
pageKey?: string,
) {
const body = await _retry(async () => {
const url = new URL(
`https://api.g.alchemy.com/data/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}/assets/tokens/by-address`,
// Use first network for URL, as Portfolio API supports multiple networks in request body
const url = new URL(getStatusRpcUrl(networks[0]))
return _fetch<TokensBalanceResponseBody>(
url,
'POST',
0,
{
addresses: [
{
address,
networks: networks.map(network => alchemyNetworks[network]),
},
],
withMetadata: false,
withPrices: false,
includeNativeTokens: true,
...(pageKey && { pageKey }),
},
STATUS_RPC_AUTH,
)
return _fetch<TokensBalanceResponseBody>(url, 'POST', 0, {
addresses: [
{
address,
networks: networks.map(network => alchemyNetworks[network]),
},
],
withMetadata: false,
withPrices: false,
includeNativeTokens: true,
...(pageKey && { pageKey }),
})
})
return body.data
@@ -209,26 +259,30 @@ export async function fetchTokenBalanceHistory(
{
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
const url = new URL(getStatusRpcUrl(network))
return _fetch<TokenBalanceHistoryResponseBody>(
url,
'POST',
0,
{
jsonrpc: '2.0',
method: 'alchemy_getAssetTransfers',
params: [
{
fromBlock: '0x0',
toBlock: 'latest',
toAddress: address,
category: [contract ? 'erc20' : 'external'],
order: 'desc',
withMetadata: true,
excludeZeroValue: true,
maxCount: '0x3e8',
...(contract && { contractAddresses: [contract] }),
},
],
},
STATUS_RPC_AUTH,
)
return _fetch<TokenBalanceHistoryResponseBody>(url, 'POST', 0, {
jsonrpc: '2.0',
method: 'alchemy_getAssetTransfers',
params: [
{
fromBlock: '0x0',
toBlock: 'latest',
toAddress: address,
category: [contract ? 'erc20' : 'external'],
order: 'desc',
withMetadata: true,
excludeZeroValue: true,
maxCount: '0x3e8',
...(contract && { contractAddresses: [contract] }),
},
],
})
})
transfers.push(...body.result.transfers)
@@ -238,26 +292,30 @@ export async function fetchTokenBalanceHistory(
{
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
const url = new URL(getStatusRpcUrl(network))
return _fetch<TokenBalanceHistoryResponseBody>(
url,
'POST',
0,
{
jsonrpc: '2.0',
method: 'alchemy_getAssetTransfers',
params: [
{
fromBlock: '0x0',
toBlock: 'latest',
fromAddress: address,
category: [contract ? 'erc20' : 'external'],
order: 'desc',
withMetadata: true,
excludeZeroValue: true,
maxCount: '0x3e8',
...(contract && { contractAddresses: [contract] }),
},
],
},
STATUS_RPC_AUTH,
)
return _fetch<TokenBalanceHistoryResponseBody>(url, 'POST', 0, {
jsonrpc: '2.0',
method: 'alchemy_getAssetTransfers',
params: [
{
fromBlock: '0x0',
toBlock: 'latest',
fromAddress: address,
category: [contract ? 'erc20' : 'external'],
order: 'desc',
withMetadata: true,
excludeZeroValue: true,
maxCount: '0x3e8',
...(contract && { contractAddresses: [contract] }),
},
],
})
})
transfers.push(...body.result.transfers)
@@ -402,9 +460,7 @@ export async function getNFTs(
pageSize?: string,
) {
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/nft/v3/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}/getNFTsForOwner`,
)
const url = new URL(getStatusRpcUrl(network))
url.searchParams.set('owner', address)
url.searchParams.set('withMetadata', 'true')
url.searchParams.set('pageSize', pageSize ?? '100')
@@ -412,7 +468,7 @@ export async function getNFTs(
if (page) {
url.searchParams.set('pageKey', page)
}
return _fetch<NFTsResponseBody>(url, 'GET', 0)
return _fetch<NFTsResponseBody>(url, 'GET', 0, undefined, STATUS_RPC_AUTH)
})
return body
@@ -429,12 +485,16 @@ export async function getNFTMetadata(
network: NetworkType,
) {
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/nft/v3/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}/getNFTMetadata`,
)
const url = new URL(getStatusRpcUrl(network))
url.searchParams.set('contractAddress', contract)
url.searchParams.set('tokenId', tokenId)
return _fetch<NFTMetadataResponseBody>(url, 'GET', 0)
return _fetch<NFTMetadataResponseBody>(
url,
'GET',
0,
undefined,
STATUS_RPC_AUTH,
)
})
return body
@@ -444,15 +504,19 @@ export async function getLatestBlockNumber(
network: NetworkType,
): Promise<number> {
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
const url = new URL(getStatusRpcUrl(network))
return _fetch<BlockNumberResponseBody>(
url,
'POST',
0,
{
jsonrpc: '2.0',
method: 'eth_blockNumber',
params: [],
id: 1,
},
STATUS_RPC_AUTH,
)
return _fetch<BlockNumberResponseBody>(url, 'POST', 0, {
jsonrpc: '2.0',
method: 'eth_blockNumber',
params: [],
id: 1,
})
})
if (!body.result) {
@@ -501,9 +565,7 @@ export async function getOutgoingAssetTransfers(
}
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
)
const url = new URL(getStatusRpcUrl(network))
return _fetch<
TokenBalanceHistoryResponseBody & {
result: {
@@ -511,12 +573,18 @@ export async function getOutgoingAssetTransfers(
pageKey?: string
}
}
>(url, 'POST', 0, {
jsonrpc: '2.0',
method: 'alchemy_getAssetTransfers',
params: [params, 'latest'],
id: Date.now(),
})
>(
url,
'POST',
0,
{
jsonrpc: '2.0',
method: 'alchemy_getAssetTransfers',
params: [params, 'latest'],
id: Date.now(),
},
STATUS_RPC_AUTH,
)
})
if ('error' in body) {
@@ -585,9 +653,7 @@ export async function getIncomingAssetTransfers(
}
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
)
const url = new URL(getStatusRpcUrl(network))
return _fetch<
TokenBalanceHistoryResponseBody & {
result: {
@@ -595,12 +661,18 @@ export async function getIncomingAssetTransfers(
pageKey?: string
}
}
>(url, 'POST', 0, {
jsonrpc: '2.0',
method: 'alchemy_getAssetTransfers',
params: [params, 'latest'],
id: Date.now(),
})
>(
url,
'POST',
0,
{
jsonrpc: '2.0',
method: 'alchemy_getAssetTransfers',
params: [params, 'latest'],
id: Date.now(),
},
STATUS_RPC_AUTH,
)
})
if ('error' in body) {
@@ -789,15 +861,19 @@ export async function getTransactionStatus(
network: NetworkType,
): Promise<'pending' | 'success' | 'failed' | 'unknown'> {
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
const url = new URL(getStatusRpcUrl(network))
return _fetch<TransactionStatusResponseBody>(
url,
'POST',
0,
{
jsonrpc: '2.0',
method: 'eth_getTransactionReceipt',
params: [txHash],
id: 1,
},
STATUS_RPC_AUTH,
)
return _fetch<TransactionStatusResponseBody>(url, 'POST', 0, {
jsonrpc: '2.0',
method: 'eth_getTransactionReceipt',
params: [txHash],
id: 1,
})
})
const transactionReceipt = body.result
@@ -826,15 +902,19 @@ export async function deprecated_getNFTSale(
network: NetworkType,
) {
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/nft/v3/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}/getNFTSales`,
)
const url = new URL(getStatusRpcUrl(network))
url.searchParams.set('contractAddress', contract)
url.searchParams.set('tokenId', tokenId)
url.searchParams.set('buyerAddress', address)
url.searchParams.set('order', 'desc')
url.searchParams.set('taker', 'BUYER')
return _fetch<deprecated_NFTSaleResponseBody>(url, 'GET', 0)
return _fetch<deprecated_NFTSaleResponseBody>(
url,
'GET',
0,
undefined,
STATUS_RPC_AUTH,
)
})
return body
@@ -850,11 +930,15 @@ export async function deprecated_getNFTSale(
*/
export async function getNFTFloorPrice(contract: string, network: NetworkType) {
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/nft/v3/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}/getFloorPrice`,
)
const url = new URL(getStatusRpcUrl(network))
url.searchParams.set('contractAddress', contract)
return _fetch<NFTFloorPriceResponseBody>(url, 'GET', 0)
return _fetch<NFTFloorPriceResponseBody>(
url,
'GET',
0,
undefined,
STATUS_RPC_AUTH,
)
})
return body
@@ -883,53 +967,66 @@ export async function getFeeRate(
;[gasEstimate, maxPriorityFee, latestBlock, feeHistory, ethPriceData] =
await Promise.all([
_retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
const url = new URL(getStatusRpcUrl(network))
return _fetch<GasEstimateResponseBody>(
url,
'POST',
0,
{
jsonrpc: '2.0',
id: 1,
method: 'eth_estimateGas',
params: [gasParams],
},
STATUS_RPC_AUTH,
)
return _fetch<GasEstimateResponseBody>(url, 'POST', 0, {
jsonrpc: '2.0',
id: 1,
method: 'eth_estimateGas',
params: [gasParams],
})
}),
_retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
const url = new URL(getStatusRpcUrl(network))
return _fetch<MaxPriorityFeeResponseBody>(
url,
'POST',
0,
{
jsonrpc: '2.0',
id: 2,
method: 'eth_maxPriorityFeePerGas',
params: [],
},
STATUS_RPC_AUTH,
)
return _fetch<MaxPriorityFeeResponseBody>(url, 'POST', 0, {
jsonrpc: '2.0',
id: 2,
method: 'eth_maxPriorityFeePerGas',
params: [],
})
}),
_retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
const url = new URL(getStatusRpcUrl(network))
return _fetch<BlockResponseBody>(
url,
'POST',
0,
{
jsonrpc: '2.0',
id: 3,
method: 'eth_getBlockByNumber',
params: ['latest', false],
},
STATUS_RPC_AUTH,
)
return _fetch<BlockResponseBody>(url, 'POST', 0, {
jsonrpc: '2.0',
id: 3,
method: 'eth_getBlockByNumber',
params: ['latest', false],
})
}),
_retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
const url = new URL(getStatusRpcUrl(network))
return _fetch<FeeHistoryResponseBody>(
url,
'POST',
0,
{
jsonrpc: '2.0',
id: 4,
method: 'eth_feeHistory',
params: ['0x4', 'latest', [10, 50, 90]],
},
STATUS_RPC_AUTH,
)
return _fetch<FeeHistoryResponseBody>(url, 'POST', 0, {
jsonrpc: '2.0',
id: 4,
method: 'eth_feeHistory',
params: ['0x4', 'latest', [10, 50, 90]],
})
}),
legacy_fetchTokensPrice(
['ETH'],
CRYPTOCOMPARE_REVALIDATION_TIMES.TRADING_PRICE,
),
fetchTokensPrice(['ETH'], COINGECKO_REVALIDATION_TIMES.TRADING_PRICE),
])
} catch (error) {
console.error('Failed to fetch fee rate:', error)
@@ -955,7 +1052,7 @@ export async function getFeeRate(
reward: feeHistory?.result?.reward ?? null,
})
const ethPrice = ethPriceData?.['ETH']?.['USD']?.['PRICE'] || 0
const ethPrice = ethPriceData?.['ETH']?.usd || 0
const feeEth = parseFloat(formatEther(gasLimit * (baseFee + priorityFee)))
const feeEur = parseFloat((ethPrice > 0 ? feeEth * ethPrice : 0).toFixed(6))
@@ -988,15 +1085,19 @@ export async function broadcastTransaction(
network: NetworkType = 'ethereum',
) {
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
const url = new URL(getStatusRpcUrl(network))
return _fetch<SendRawTransactionResponseBody>(
url,
'POST',
0,
{
jsonrpc: '2.0',
id: 1,
method: 'eth_sendRawTransaction',
params: [txHex],
},
STATUS_RPC_AUTH,
)
return _fetch<SendRawTransactionResponseBody>(url, 'POST', 0, {
jsonrpc: '2.0',
id: 1,
method: 'eth_sendRawTransaction',
params: [txHex],
})
})
return body
@@ -1010,15 +1111,19 @@ export async function getTransactionCount(
network: NetworkType,
) {
const body = await _retry(async () => {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
const url = new URL(getStatusRpcUrl(network))
return _fetch<TransactionCountResponseBody>(
url,
'POST',
0,
{
jsonrpc: '2.0',
id: 1,
method: 'eth_getTransactionCount',
params: [address, 'latest'],
},
STATUS_RPC_AUTH,
)
return _fetch<TransactionCountResponseBody>(url, 'POST', 0, {
jsonrpc: '2.0',
id: 1,
method: 'eth_getTransactionCount',
params: [address, 'latest'],
})
})
const nonce = body.result
@@ -1042,12 +1147,22 @@ async function _fetch<T extends ResponseBody>(
method: 'GET' | 'POST',
revalidate: number,
body?: Record<string, unknown>,
auth?: { username: string; password: string },
): Promise<T> {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
if (auth) {
const credentials = Buffer.from(
`${auth.username}:${auth.password}`,
).toString('base64')
headers['Authorization'] = `Basic ${credentials}`
}
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
headers,
...(body && { body: JSON.stringify(body) }),
// why: https://nextjs.org/docs/app/building-your-application/data-fetching/fetching#reusing-data-across-multiple-functions
// why: https://github.com/vercel/next.js/issues/70946
@@ -1,8 +1,45 @@
/**
* CoinGecko API Service
* @see https://docs.coingecko.com/ for CoinGecko API documentation
*
* Uses proxy endpoint: serverEnv.MARKET_PROXY_URL
*/
import retry from 'async-retry'
import { serverEnv } from '../../../config/env.server.mjs'
import erc20TokenList from '../../../constants/erc20.json'
import { DEFAULT_TOKEN_IDS } from '../../api/routers/assets'
import { markApiKeyAsRateLimited } from '../api-key-rotation'
import type { NetworkType } from '../../api/types'
import type {
CoinGeckoCoinDetailResponse,
CoinGeckoCoinHistoryResponse,
CoinGeckoCoinListResponse,
CoinGeckoMarketChartResponse,
CoinGeckoNFTFloorPriceResponse,
CoinGeckoSimplePriceResponse,
} from './types'
const PROXY_BASE_URL = serverEnv.MARKET_PROXY_URL
const PROXY_AUTH = {
username: serverEnv.MARKET_PROXY_AUTH_USERNAME,
password: serverEnv.MARKET_PROXY_AUTH_PASSWORD,
}
export const COINGECKO_REVALIDATION_TIMES = {
TRADING_PRICE: 15,
CURRENT_PRICE: 60,
PRICE_HISTORY: 3600,
PRICE_HISTORY_DAILY: 3600,
TOKEN_METADATA: 3600,
PRICE_FOR_DATE: 15,
} as const
type Revalidation =
(typeof COINGECKO_REVALIDATION_TIMES)[keyof typeof COINGECKO_REVALIDATION_TIMES]
const coingeckoNetworks = {
ethereum: 'ethereum',
@@ -22,161 +59,589 @@ const coingeckoNativeTokens = {
bsc: 'ethereum',
}
export const getNativeTokenPrice = async (network: NetworkType) => {
const response = await fetch(
`https://api.coingecko.com/api/v3/simple/price?ids=${coingeckoNativeTokens[network]}&vs_currencies=usd&include_24hr_change=true`,
{
headers: {
'Content-Type': 'application/json',
'x-cg-demo-api-key': serverEnv.COINGECKO_API_KEY,
'Cache-Control': 'private, max-age=3600',
},
next: {
revalidate: 3600,
},
// Cache for coin list to avoid repeated API calls
let coinListCache: CoinGeckoCoinListResponse | null = null
let coinListCacheTime = 0
const COIN_LIST_CACHE_TTL = 3600 * 1000 // 1 hour
/**
* Fetch with Basic Auth (for proxy)
*/
async function _fetchWithAuth<T>(
url: URL,
revalidate: number,
tag: string,
): Promise<T> {
const credentials = Buffer.from(
`${PROXY_AUTH.username}:${PROXY_AUTH.password}`,
).toString('base64')
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Basic ${credentials}`,
'Content-Type': 'application/json',
},
)
cache: 'force-cache',
next: {
revalidate,
tags: [tag],
},
})
if (!response.ok) {
console.error(response.statusText)
throw new Error(`Failed to fetch prices for native token`)
let errorMessage = response.statusText
try {
const errorBody = await response.text()
if (errorBody) {
try {
const parsed = JSON.parse(errorBody)
errorMessage = parsed.error?.message || parsed.message || errorBody
} catch {
errorMessage = errorBody
}
}
} catch {
// Ignore errors when reading error body
}
console.error(
`Failed to fetch ${url.toString()}: ${response.status} ${errorMessage}`,
)
if (response.status === 429) {
const apiKey = url.searchParams.get('api_key')
if (apiKey) {
markApiKeyAsRateLimited(apiKey)
}
}
throw new Error(`Failed to fetch: ${response.status} ${errorMessage}`)
}
const prices = await response.json()
const body: T = await response.json()
// Check for CoinGecko error format
if (
typeof body === 'object' &&
body !== null &&
'error' in body &&
body.error
) {
const error = body.error as { message?: string }
console.error(error.message || 'Unknown error')
if (error.message?.toLowerCase().includes('rate limit')) {
const apiKey = url.searchParams.get('api_key')
if (apiKey) {
markApiKeyAsRateLimited(apiKey)
}
}
throw new Error('Failed to fetch.')
}
return body
}
/**
* Helper function to get coin list with platforms data
*/
async function _getCoinList(): Promise<CoinGeckoCoinListResponse> {
const now = Date.now()
// Use cache if available and not expired
if (
coinListCache &&
coinListCacheTime > 0 &&
now - coinListCacheTime < COIN_LIST_CACHE_TTL
) {
return coinListCache
}
const url = new URL(`${PROXY_BASE_URL}/v1/coins/list`)
url.searchParams.set('include_platform', 'true')
const coinList = await _fetchWithAuth<CoinGeckoCoinListResponse>(
url,
COINGECKO_REVALIDATION_TIMES.TOKEN_METADATA,
'coinList',
)
// Update cache
coinListCache = coinList
coinListCacheTime = now
return coinList
}
/**
* Helper: Map symbols to coin IDs from coin list
*/
function _mapSymbolsToCoinIds(
symbols: string[],
coinList: Array<{ id: string; symbol: string; name: string }>,
): Record<string, string> {
const result: Record<string, string> = {}
for (const symbol of symbols) {
const symbolLower = symbol.toLowerCase()
// Check default token IDs first
const symbolUpper = symbol.toUpperCase() as keyof typeof DEFAULT_TOKEN_IDS
if (symbolUpper in DEFAULT_TOKEN_IDS) {
const knownCoinId = DEFAULT_TOKEN_IDS[symbolUpper]
// Verify the coin ID exists in the coin list
const knownCoin = coinList.find(c => c.id === knownCoinId)
if (knownCoin) {
result[symbolLower] = knownCoinId
continue
}
}
// Find all coins with matching symbol
const matchingCoins = coinList.filter(
c => c.symbol.toLowerCase() === symbolLower,
)
if (matchingCoins.length === 0) {
continue
}
// Prefer coin where coin ID exactly matches the symbol (e.g., "pepe" -> "pepe")
const exactMatch = matchingCoins.find(
c => c.id.toLowerCase() === symbolLower,
)
if (exactMatch) {
result[symbolLower] = exactMatch.id
} else {
// If no exact match, use the first one found
const coin = matchingCoins[0]
result[symbolLower] = coin.id
}
}
return result
}
/**
* Helper function to get coin ids from symbols
*/
async function _getCoinIdsFromSymbols(
symbols: string[],
): Promise<Record<string, string>> {
const now = Date.now()
// Use cache if available and not expired
if (
coinListCache &&
coinListCacheTime > 0 &&
now - coinListCacheTime < COIN_LIST_CACHE_TTL
) {
// Filter out coins without required fields for mapping
const validCoins = coinListCache.filter(c => c.symbol && c.name) as Array<{
id: string
symbol: string
name: string
}>
return _mapSymbolsToCoinIds(symbols, validCoins)
}
// Fetch coin list with platforms data
const coinList = await _getCoinList()
// Filter out coins without required fields for mapping
const validCoins = coinList.filter(c => c.symbol && c.name) as Array<{
id: string
symbol: string
name: string
}>
return _mapSymbolsToCoinIds(symbols, validCoins)
}
/**
* Helper function to get coin ID from contract address
*/
async function _getCoinIdFromAddress(symbol: string): Promise<string | null> {
try {
if (!erc20TokenList?.tokens) {
return null
}
// Find token in erc20.json by symbol and chainId 1 (ethereum)
const token = erc20TokenList.tokens.find(
t => t.symbol.toUpperCase() === symbol.toUpperCase() && t.chainId === 1,
)
if (!token?.address) {
return null
}
// Get coin list and find coin ID by contract address
const coinList = await _getCoinList()
const normalizedAddress = token.address.toLowerCase()
// Find coin where platforms.ethereum matches the address
const coin = coinList.find(
c => c.platforms?.['ethereum']?.toLowerCase() === normalizedAddress,
)
if (!coin?.id) {
return null
}
return coin.id
} catch {
return null
}
}
/**
* Helper function to get coin id from symbol
*/
export async function getCoinIdFromSymbol(
symbol: string,
): Promise<string | null> {
const symbolLower = symbol.toLowerCase()
// Check default token IDs first
const symbolUpper = symbol.toUpperCase() as keyof typeof DEFAULT_TOKEN_IDS
if (symbolUpper in DEFAULT_TOKEN_IDS) {
const knownCoinId = DEFAULT_TOKEN_IDS[symbolUpper]
// Verify the coin ID exists in the coin list
const coinList = await _getCoinList()
const knownCoin = coinList.find(c => c.id === knownCoinId)
if (knownCoin) {
return knownCoinId
}
}
// Try to get coin ID from contract address
const coinIdFromAddress = await _getCoinIdFromAddress(symbol)
if (coinIdFromAddress) {
return coinIdFromAddress
}
// Fallback to symbol-based lookup
const coinIdMap = await _getCoinIdsFromSymbols([symbol])
return coinIdMap[symbolLower] || null
}
/**
* @see https://docs.coingecko.com/reference/coins-id-market-chart
*
* Fetches token price history using CoinGecko API.
*/
export async function fetchTokenPriceHistory(
symbol: string,
days: '1' | '7' | '30' | '90' | '365' | 'all' = '1',
revalidate: Revalidation = COINGECKO_REVALIDATION_TIMES.PRICE_HISTORY,
): Promise<CoinGeckoMarketChartResponse> {
const coinId = await getCoinIdFromSymbol(symbol)
if (!coinId) {
throw new Error(`Coin not found for symbol: ${symbol}`)
}
// Map days to CoinGecko format
const daysParam = days === 'all' ? 'max' : days
const url = new URL(`${PROXY_BASE_URL}/v1/coins/${coinId}/market_chart`)
url.searchParams.set('vs_currency', 'usd')
url.searchParams.set('days', daysParam)
return _fetchWithAuth<CoinGeckoMarketChartResponse>(
url,
revalidate,
'fetchTokenPriceHistory',
)
}
/**
* @see https://docs.coingecko.com/reference/coins-id
*
* Fetches token metadata using CoinGecko API.
*/
export async function fetchTokenMetadata(
symbol: string,
revalidate: Revalidation = COINGECKO_REVALIDATION_TIMES.TOKEN_METADATA,
): Promise<CoinGeckoCoinDetailResponse> {
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/${coinId}`)
url.searchParams.set('localization', 'true')
url.searchParams.set('tickers', 'false')
url.searchParams.set('market_data', 'true')
url.searchParams.set('community_data', 'false')
url.searchParams.set('developer_data', 'false')
url.searchParams.set('sparkline', 'false')
const result = await _fetchWithAuth<CoinGeckoCoinDetailResponse>(
url,
revalidate,
'fetchTokenMetadata',
)
return result
}
/**
* @see https://docs.coingecko.com/reference/simple-price
*
* Fetches current prices for multiple tokens using CoinGecko API.
*/
export async function fetchTokensPrice(
symbols: string[],
revalidate: Revalidation = COINGECKO_REVALIDATION_TIMES.CURRENT_PRICE,
): Promise<Record<string, CoinGeckoSimplePriceResponse[string]>> {
if (symbols.length === 0) return {}
try {
const coinIdMap = await _getCoinIdsFromSymbols(symbols)
const coinIds = Object.values(coinIdMap).filter(Boolean)
if (coinIds.length === 0) return {}
const url = new URL(`${PROXY_BASE_URL}/v1/simple/price`)
url.searchParams.set('ids', coinIds.join(','))
url.searchParams.set('vs_currencies', 'usd')
url.searchParams.set('include_24hr_change', 'true')
url.searchParams.set('include_market_cap', 'true')
url.searchParams.set('include_24hr_vol', 'true')
const priceData = await _fetchWithAuth<CoinGeckoSimplePriceResponse>(
url,
revalidate,
'fetchTokensPrice',
)
// Map by symbol for backward compatibility
const data: Record<string, CoinGeckoSimplePriceResponse[string]> = {}
for (const symbol of symbols) {
const coinId = coinIdMap[symbol.toLowerCase()]
if (!coinId || !priceData[coinId]) continue
// Store with both original symbol and uppercase symbol for compatibility
data[symbol] = priceData[coinId]
data[symbol.toUpperCase()] = priceData[coinId]
}
return data
} catch (error: unknown) {
console.error('Failed to fetch prices:', error)
return {}
}
}
/**
* Returns the price of tokens at a specific timestamp.
* @see https://docs.coingecko.com/reference/coins-id-history
*/
export async function fetchTokensPriceForDate(
symbols: string[],
timestamp: number,
revalidate: Revalidation = COINGECKO_REVALIDATION_TIMES.PRICE_FOR_DATE,
): Promise<Record<string, { usd: number }>> {
const data: Record<string, { usd: number }> = {}
// Get coin ids from symbols
const coinIdMap = await _getCoinIdsFromSymbols(symbols)
for (const symbol of symbols) {
try {
const coinId = coinIdMap[symbol.toLowerCase()]
if (!coinId) {
console.warn(`Coin not found for symbol: ${symbol}`)
continue
}
// Convert timestamp to date
const date = new Date(timestamp * 1000)
const dateStr = date.toISOString().split('T')[0] // YYYY-MM-DD
const url = new URL(`${PROXY_BASE_URL}/v1/coins/${coinId}/history`)
url.searchParams.set('date', dateStr)
url.searchParams.set('localization', 'false')
const historyData = await _fetchWithAuth<CoinGeckoCoinHistoryResponse>(
url,
revalidate,
'fetchTokensPriceForDate',
)
if (historyData.market_data?.current_price?.usd) {
data[symbol.toUpperCase()] = {
usd: historyData.market_data.current_price.usd,
}
}
} catch (err) {
console.warn(`No price data for ${symbol}:`, String(err))
}
}
return data
}
/**
* @see https://docs.coingecko.com/reference/coins-list
*
* Returns coin list from CoinGecko API.
*/
export async function getCoinList(): Promise<CoinGeckoCoinListResponse> {
return _getCoinList()
}
/**
* @see https://docs.coingecko.com/reference/simple-price
*
* Fetches native token price for a network.
*/
export const getNativeTokenPrice = async (network: NetworkType) => {
const url = new URL(`${PROXY_BASE_URL}/v1/simple/price`)
url.searchParams.set('ids', coingeckoNativeTokens[network])
url.searchParams.set('vs_currencies', 'usd')
url.searchParams.set('include_24hr_change', 'true')
const prices = await _fetchWithAuth<CoinGeckoSimplePriceResponse>(
url,
COINGECKO_REVALIDATION_TIMES.CURRENT_PRICE,
'getNativeTokenPrice',
)
return prices[coingeckoNativeTokens[network]]
}
/**
* @see https://docs.coingecko.com/reference/coins-id-market-chart
*
* Fetches native token price chart data for a network.
*/
export const getNativeTokenPriceChartData = async (
network: NetworkType,
days: '1' | '7' | '30' | '90' | '365' | 'all' = '1',
) => {
const response = await fetch(
`https://api.coingecko.com/api/v3/coins/${coingeckoNativeTokens[network]}/market_chart?vs_currency=usd&days=${days}`,
{
next: {
revalidate: 3600,
},
},
const url = new URL(
`${PROXY_BASE_URL}/v1/coins/${coingeckoNativeTokens[network]}/market_chart`,
)
url.searchParams.set('vs_currency', 'usd')
url.searchParams.set('days', days === 'all' ? 'max' : days)
const chartData = await _fetchWithAuth<CoinGeckoMarketChartResponse>(
url,
COINGECKO_REVALIDATION_TIMES.PRICE_HISTORY,
'getNativeTokenPriceChartData',
)
if (!response.ok) {
console.error(response.statusText)
throw new Error(`Failed to fetch chart data for native token`)
}
const chartData = await response.json()
const prices = chartData.prices
return calculateHourlyPrices(prices)
return calculateHourlyPrices(chartData.prices)
}
/**
* @see https://docs.coingecko.com/reference/coins-id-market-chart
*
* Fetches ERC20 token price chart data.
*/
export const getERC20TokenPriceChartData = async (
address: string,
network: NetworkType,
days: '1' | '7' | '30' | '90' | '365' | 'all' = '1',
) => {
const response = await fetch(
`https://api.coingecko.com/api/v3/coins/${coingeckoNetworks[network]}/contract/${address}/market_chart?vs_currency=usd&days=${days}`,
{
next: {
revalidate: 3600,
},
},
const url = new URL(
`${PROXY_BASE_URL}/v1/coins/${coingeckoNetworks[network]}/contract/${address}/market_chart`,
)
url.searchParams.set('vs_currency', 'usd')
url.searchParams.set('days', days === 'all' ? 'max' : days)
const chartData = await _fetchWithAuth<CoinGeckoMarketChartResponse>(
url,
COINGECKO_REVALIDATION_TIMES.PRICE_HISTORY,
'getERC20TokenPriceChartData',
)
if (!response.ok) {
console.error(response.statusText)
throw new Error(`Failed to fetch chart data for erc20 token`)
}
const chartData = await response.json()
const prices = chartData.prices
return calculateHourlyPrices(prices)
return calculateHourlyPrices(chartData.prices)
}
// todo: rename to marketdata
export const getNativeTokenMetadata = async (network: NetworkType) => {
const response = await fetch(
`https://api.coingecko.com/api/v3/coins/${coingeckoNativeTokens[network]}?=localization=false&tickers=false&market_data=true&community_data=false&developer_data=false&sparkline=true`,
{
next: {
revalidate: 3600,
},
},
/**
* @see https://docs.coingecko.com/reference/coins-id
*
* Fetches native token metadata.
*/
export const getNativeTokenMetadata = async (
network: NetworkType,
): Promise<CoinGeckoCoinDetailResponse> => {
const url = new URL(
`${PROXY_BASE_URL}/v1/coins/${coingeckoNativeTokens[network]}`,
)
url.searchParams.set('localization', 'false')
url.searchParams.set('tickers', 'false')
url.searchParams.set('market_data', 'true')
url.searchParams.set('community_data', 'false')
url.searchParams.set('developer_data', 'false')
url.searchParams.set('sparkline', 'true')
if (!response.ok) {
console.error(response.statusText)
throw new Error(`Failed to fetch metadata for native token`)
}
const metadata = await response.json()
return metadata
}
export const getERC20TokenMetadata = async (address: string) => {
const response = await fetch(
`https://api.coingecko.com/api/v3/coins/id/contract/${address}?=localization=false&tickers=false&market_data=true&community_data=false&developer_data=false&sparkline=true`,
{
next: {
revalidate: 3600,
},
},
return _fetchWithAuth<CoinGeckoCoinDetailResponse>(
url,
COINGECKO_REVALIDATION_TIMES.TOKEN_METADATA,
'getNativeTokenMetadata',
)
if (!response.ok) {
console.error(response.statusText)
throw new Error(`Failed to fetch metadata for erc20 token`)
}
const metadata = await response.json()
return metadata
}
/**
* @see https://docs.coingecko.com/reference/coins-id
*
* Fetches ERC20 token metadata by contract address.
*/
export const getERC20TokenMetadata = async (
address: string,
network: NetworkType,
): Promise<CoinGeckoCoinDetailResponse> => {
const url = new URL(
`${PROXY_BASE_URL}/v1/coins/${coingeckoNetworks[network]}/contract/${address}`,
)
url.searchParams.set('localization', 'false')
url.searchParams.set('tickers', 'false')
url.searchParams.set('market_data', 'true')
url.searchParams.set('community_data', 'false')
url.searchParams.set('developer_data', 'false')
url.searchParams.set('sparkline', 'true')
return _fetchWithAuth<CoinGeckoCoinDetailResponse>(
url,
COINGECKO_REVALIDATION_TIMES.TOKEN_METADATA,
'getERC20TokenMetadata',
)
}
/**
* @see https://docs.coingecko.com/reference/simple-token-price
*
* Fetches ERC20 tokens price by contract addresses.
*/
export const getERC20TokensPrice = async (
addresses: string[],
network: NetworkType,
) => {
): Promise<CoinGeckoSimplePriceResponse> => {
const _addresses = addresses.filter(Boolean)
if (!_addresses.length) {
return {
[network]: null,
}
return {}
}
const response = await retry(
async () => {
const result = await fetch(
`https://api.coingecko.com/api/v3/simple/token_price/${
coingeckoNetworks[network]
}?contract_addresses=${addresses.join(
',',
)}&vs_currencies=usd&include_24hr_change=true`,
{
headers: {
'Content-Type': 'application/json',
'x-cg-demo-api-key': serverEnv.COINGECKO_API_KEY,
'Cache-Control': 'private, max-age=3600',
},
next: {
revalidate: 3600,
},
},
const url = new URL(
`${PROXY_BASE_URL}/v1/simple/token_price/${coingeckoNetworks[network]}`,
)
url.searchParams.set('contract_addresses', _addresses.join(','))
url.searchParams.set('vs_currencies', 'usd')
url.searchParams.set('include_24hr_change', 'true')
if (result.ok) {
return result
}
// `Too many requests` https://docs.coingecko.com/reference/common-errors-rate-limit
if (result.status === 429) {
throw new Error(result.statusText)
}
const result = await _fetchWithAuth<CoinGeckoSimplePriceResponse>(
url,
COINGECKO_REVALIDATION_TIMES.CURRENT_PRICE,
'getERC20TokensPrice',
)
return result
},
@@ -189,155 +654,50 @@ export const getERC20TokensPrice = async (
},
)
if (!response.ok) {
console.error(response.statusText)
throw new Error(`Failed to fetch prices for ${network}`)
}
const prices = await response.json()
return prices
return response
}
export const getTokenList = async () => {
const response = await fetch('https://api.coingecko.com/api/v3/coins/list', {
next: {
revalidate: 3600, // 1 hour
},
headers: {
'x-cg-demo-api-key': serverEnv.COINGECKO_API_KEY,
},
})
const tokens = await response.json()
/**
* @see https://docs.coingecko.com/reference/coins-list
*
* Fetches token list from CoinGecko.
*/
export const getTokenList = async (): Promise<CoinGeckoCoinListResponse> => {
const url = new URL(`${PROXY_BASE_URL}/v1/coins/list`)
url.searchParams.set('include_platform', 'true')
if (!response.ok) {
console.error(response.statusText)
throw new Error('Failed to fetch tokens')
}
return tokens
return _fetchWithAuth<CoinGeckoCoinListResponse>(
url,
COINGECKO_REVALIDATION_TIMES.TOKEN_METADATA,
'getTokenList',
)
}
type NFTFloorPriceResponseBody = {
id: string
contract_address: string
asset_platform_id: string
name: string
symbol: string
image: {
small: string
}
banner_image: string
description: string
native_currency: string
native_currency_symbol: string
floor_price: {
native_currency: number
usd: number
}
market_cap: {
native_currency: number
usd: number
}
volume_24h: {
native_currency: number
usd: number
}
floor_price_in_usd_24h_percentage_change: number
floor_price_24h_percentage_change: {
usd: number
native_currency: number
}
market_cap_24h_percentage_change: {
usd: number
native_currency: number
}
volume_24h_percentage_change: {
usd: number
native_currency: number
}
number_of_unique_addresses: number
number_of_unique_addresses_24h_percentage_change: number
volume_in_usd_24h_percentage_change: number
total_supply: number
one_day_sales: number
one_day_sales_24h_percentage_change: number
one_day_average_sale_price: number
one_day_average_sale_price_24h_percentage_change: number
links: {
homepage: string
twitter: string
discord: string
}
floor_price_7d_percentage_change: {
usd: number
native_currency: number
}
floor_price_14d_percentage_change: {
usd: number
native_currency: number
}
floor_price_30d_percentage_change: {
usd: number
native_currency: number
}
floor_price_60d_percentage_change: {
usd: number
native_currency: number
}
floor_price_1y_percentage_change: {
usd: number
native_currency: number
}
explorers: Array<{
name: string
link: string
}>
user_favorites_count: number
ath: {
native_currency: number
usd: number
}
ath_change_percentage: {
native_currency: number
usd: number
}
ath_date: {
native_currency: string
usd: string
}
}
/**
* @see https://docs.coingecko.com/v3.0.1/reference/nfts-contract-address
*
* Fetches NFT floor price.
*/
export const getNFTFloorPrice = async (
contract: string,
network: NetworkType,
) => {
): Promise<number> => {
const url = new URL(
`https://api.coingecko.com/api/v3/nfts/${coingeckoNetworks[network]}/contract/${contract}`,
`${PROXY_BASE_URL}/v1/nfts/${coingeckoNetworks[network]}/contract/${contract}`,
)
const response = await fetch(url, {
headers: {
'x-cg-demo-api-key': serverEnv.COINGECKO_API_KEY,
},
next: {
revalidate: 3600,
},
})
const body = await _fetchWithAuth<CoinGeckoNFTFloorPriceResponse>(
url,
COINGECKO_REVALIDATION_TIMES.TOKEN_METADATA,
'getNFTFloorPrice',
)
if (!response.ok) {
console.error(response.statusText)
throw new Error(`Failed to fetch nft floor price`)
}
const body: NFTFloorPriceResponseBody = await response.json()
const data = body.floor_price.native_currency
return data
return body.floor_price.native_currency
}
/**
* Calculate hourly prices from price data
*/
const calculateHourlyPrices = (prices: [number, number][]) => {
const hourlyPrices = prices.reduce(
(acc: [number, number][], curr: [number, number]) => {
@@ -368,3 +728,25 @@ const calculateHourlyPrices = (prices: [number, number][]) => {
// Sort the hourly prices by timestamp
return hourlyPrices.sort((a, b) => a[0] - b[0])
}
// Legacy function names for backward compatibility (will be removed)
export const legacy_fetchTokenPriceHistory = fetchTokenPriceHistory
export const legacy_fetchTokensPrice = fetchTokensPrice
export const legacy_research_fetchTokenMetadata = async (
symbol: string,
_revalidate: Revalidation = COINGECKO_REVALIDATION_TIMES.TOKEN_METADATA,
) => {
// _revalidate is kept for backward compatibility but not used
void _revalidate
const coinList = await _getCoinList()
const coin = coinList.find(
c => c.symbol.toLowerCase() === symbol.toLowerCase(),
)
if (!coin) {
throw new Error(`Coin not found for symbol: ${symbol}`)
}
return coin
}
// Export revalidation times with legacy name for backward compatibility
export const MARKET_PROXY_REVALIDATION_TIMES = COINGECKO_REVALIDATION_TIMES
@@ -0,0 +1,216 @@
/**
* CoinGecko API Response Types
* @see https://docs.coingecko.com/ for CoinGecko API documentation
*/
/**
* Market Chart Response
* @see https://docs.coingecko.com/reference/coins-id-market-chart
*/
export type CoinGeckoMarketChartResponse = {
prices: [number, number][]
market_caps: [number, number][]
total_volumes: [number, number][]
}
/**
* Coin Detail Response
* @see https://docs.coingecko.com/reference/coins-id
*/
export type CoinGeckoCoinDetailResponse = {
id: string
symbol: string
name: string
image: {
small: string
large: string
thumb: string
}
market_cap_rank?: number | null
market_data: {
current_price: {
usd: number
[key: string]: number
}
market_cap: {
usd: number
[key: string]: number
}
market_cap_rank?: number | null
total_volume: {
usd: number
[key: string]: number
}
price_change_24h: number
price_change_percentage_24h: number
price_change_percentage_7d?: number
price_change_percentage_30d?: number
circulating_supply: number
total_supply: number
max_supply: number | null
high_24h?: {
usd: number
[key: string]: number
}
low_24h?: {
usd: number
[key: string]: number
}
}
description: Record<string, string>
links: {
homepage: string[]
blockchain_site: string[]
whitepaper: string | null
official_forum_url?: string[]
subreddit_url?: string
repos_url?: {
github?: string[]
bitbucket?: string[]
}
}
platforms?: Record<string, string>
genesis_date?: string
contract_address?: string
}
/**
* Simple Price Response
* @see https://docs.coingecko.com/reference/simple-price
*/
export type CoinGeckoSimplePriceResponse = Record<
string,
{
usd: number
usd_24h_change?: number
usd_market_cap?: number
usd_24h_vol?: number
[key: string]: number | undefined
}
>
/**
* Coin List Response
* @see https://docs.coingecko.com/reference/coins-list
*/
export type CoinGeckoCoinListResponse = Array<{
id: string
symbol: string
name: string
platforms?: Record<string, string>
}>
/**
* Coin History Response
* @see https://docs.coingecko.com/reference/coins-id-history
*/
export type CoinGeckoCoinHistoryResponse = {
id: string
symbol: string
name: string
image: {
thumb: string
small: string
}
market_data: {
current_price: {
usd: number
[key: string]: number
}
}
localization: Record<string, string>
}
/**
* NFT Floor Price Response
* @see https://docs.coingecko.com/v3.0.1/reference/nfts-contract-address
*/
export type CoinGeckoNFTFloorPriceResponse = {
id: string
contract_address: string
asset_platform_id: string
name: string
symbol: string
image: {
small: string
}
banner_image: string
description: string
native_currency: string
native_currency_symbol: string
floor_price: {
native_currency: number
usd: number
}
market_cap: {
native_currency: number
usd: number
}
volume_24h: {
native_currency: number
usd: number
}
floor_price_in_usd_24h_percentage_change: number
floor_price_24h_percentage_change: {
usd: number
native_currency: number
}
market_cap_24h_percentage_change: {
usd: number
native_currency: number
}
volume_24h_percentage_change: {
usd: number
native_currency: number
}
number_of_unique_addresses: number
number_of_unique_addresses_24h_percentage_change: number
volume_in_usd_24h_percentage_change: number
total_supply: number
one_day_sales: number
one_day_sales_24h_percentage_change: number
one_day_average_sale_price: number
one_day_average_sale_price_24h_percentage_change: number
links: {
homepage: string
twitter: string
discord: string
}
floor_price_7d_percentage_change: {
usd: number
native_currency: number
}
floor_price_14d_percentage_change: {
usd: number
native_currency: number
}
floor_price_30d_percentage_change: {
usd: number
native_currency: number
}
floor_price_60d_percentage_change: {
usd: number
native_currency: number
}
floor_price_1y_percentage_change: {
usd: number
native_currency: number
}
explorers: Array<{
name: string
link: string
}>
user_favorites_count: number
ath: {
native_currency: number
usd: number
}
ath_change_percentage: {
native_currency: number
usd: number
}
ath_date: {
native_currency: string
usd: string
}
}
@@ -1,341 +0,0 @@
/**
* @see https://developers.cryptocompare.com/settings/api-keys for usage
* @see https://developers.cryptocompare.com/pricing for pricing
* @see https://developers.cryptocompare.com/documentation/data-api/admin_v2_rate_limit for rate limits
* @see https://status.cryptocompare.com for CryptoCompare status
* @see https://developers.cryptocompare.com/documentation for documentation
*
* @see https://github.com/status-im/status-go/blob/3179532b645549c103266e007694d2c81a7091b4/services/wallet/thirdparty/cryptocompare/client.go for Go implementation
*/
// import 'server-only'
import { serverEnv } from '../../../config/env.server.mjs'
import {
getRandomApiKey,
markApiKeyAsRateLimited,
markApiKeyAsSuccessful,
} from '../api-key-rotation'
import type {
deprecated_TokensMetadataResponseBody,
legacy_research_TokenMetadataResponseBody,
legacy_TokenPriceHistoryResponseBody,
legacy_TokensPriceResponseBody,
TokenMetadataResponseBody,
} from './types'
export const CRYPTOCOMPARE_REVALIDATION_TIMES = {
TRADING_PRICE: 15,
CURRENT_PRICE: 60,
PRICE_HISTORY: 3600,
PRICE_HISTORY_DAILY: 3600,
TOKEN_METADATA: 3600,
PRICE_FOR_DATE: 15,
} as const
type Revalidation =
(typeof CRYPTOCOMPARE_REVALIDATION_TIMES)[keyof typeof CRYPTOCOMPARE_REVALIDATION_TIMES]
/**
* @see https://min-api.cryptocompare.com/documentation?key=Historical&cat=dataHistoday
* @see https://min-api.cryptocompare.com/documentation?key=Historical&cat=dataHistohour
*
* note: Tries to convert to USD if CCCAGG market does not exist for coin pair.
*/
export async function legacy_fetchTokenPriceHistory(
symbol: string,
days: '1' | '7' | '30' | '90' | '365' | 'all' = '1',
revalidate: Revalidation = CRYPTOCOMPARE_REVALIDATION_TIMES.PRICE_HISTORY,
) {
if (days === 'all') {
const url = new URL('https://min-api.cryptocompare.com/data/v2/histoday')
url.searchParams.set('fsym', symbol)
url.searchParams.set('tsym', 'USD')
url.searchParams.set('allData', 'true')
url.searchParams.set('tryConversion', 'false')
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
const body = await _fetch<legacy_TokenPriceHistoryResponseBody>(
url,
revalidate,
'legacy_fetchTokenPriceHistory_today',
)
const data = body.Data.Data
return data
}
// fixme: use date-fns
const from =
Math.floor(new Date().getTime() / 1000) - Number(days) * 24 * 60 * 60
let to = Math.floor(Date.now() / 1000) // for historical data before this timestamp
let _data: legacy_TokenPriceHistoryResponseBody['Data']['Data'] = []
do {
const url = new URL('https://min-api.cryptocompare.com/data/v2/histohour')
url.searchParams.set('fsym', symbol)
url.searchParams.set('tsym', 'USD')
url.searchParams.set('toTs', to.toString())
url.searchParams.set('limit', '2000')
url.searchParams.set('tryConversion', 'false')
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
const body = await _fetch<legacy_TokenPriceHistoryResponseBody>(
url,
revalidate,
'legacy_fetchTokenPriceHistory_hour',
)
const data = body.Data.Data
_data = [...data, ..._data]
to = data[0].time
} while (to >= from)
const data = _data.filter(item => item.time >= from)
return data
}
// todo?: use for current price as well (lacks USD)
// todo?: use https://developers.cryptocompare.com/documentation/data-api/asset_v1_summary_list if full metadata is not needed
// fixme?: use CCData id instead of symbol by extending token list
// todo?: use contract address lookup https://developers.cryptocompare.com/documentation/data-api/onchain_v2_data_by_address instead
/**
* @see https://developers.cryptocompare.com/documentation/data-api/asset_v1_metadata
*/
export async function fetchTokenMetadata(
symbol: string,
revalidate: Revalidation = CRYPTOCOMPARE_REVALIDATION_TIMES.TOKEN_METADATA,
) {
const url = new URL('https://data-api.cryptocompare.com/asset/v1/metadata')
url.searchParams.set('asset', symbol)
url.searchParams.set('asset_lookup_priority', 'SYMBOL')
url.searchParams.set('quote_asset', 'USD')
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
const body = await _fetch<TokenMetadataResponseBody>(
url,
revalidate,
'fetchTokenMetadata',
)
const data = body.Data
return data
}
// todo?: use https://min-api.cryptocompare.com/data/coin/generalinfo?fsyms=OP,ARB,SNT,USDT&tsym=USD instead (no docs)
/**
* @see https://developers.cryptocompare.com/documentation/data-api/asset_v1_data_by_symbol
*/
export async function deprecated_fetchTokenMetadata(
symbol: string,
revalidate: Revalidation = CRYPTOCOMPARE_REVALIDATION_TIMES.TOKEN_METADATA,
) {
const url = new URL(
'https://data-api.cryptocompare.com/asset/v1/data/by/symbol',
)
url.searchParams.set('asset_symbol', symbol)
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
const body = await _fetch<deprecated_TokensMetadataResponseBody>(
url,
revalidate,
'deprecated_fetchTokenMetadata',
)
const data = body.Data[symbol]
return data
}
// note: not based on a full list
/**
* Returns all the coins that CryptoCompare has added to the website. This is not the full list of coins we have in the system, it is just the list of coins we have done some research on.
*
* @see https://min-api.cryptocompare.com/documentation?key=Other&cat=allCoinsWithContentEndpoint
*/
export async function legacy_research_fetchTokenMetadata(
symbol: string,
revalidate: Revalidation = CRYPTOCOMPARE_REVALIDATION_TIMES.TOKEN_METADATA,
) {
const url = new URL('https://min-api.cryptocompare.com/data/all/coinlist')
url.searchParams.set('fsym', symbol)
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
const body = await _fetch<legacy_research_TokenMetadataResponseBody>(
url,
revalidate,
'legacy_research_fetchTokenMetadata',
)
const data = body.Data[symbol]
return data
}
// todo?: use https://min-api.cryptocompare.com/documentation?key=Price&cat=multipleSymbolsPriceEndpoint insetad (not full)
/**
* @see https://min-api.cryptocompare.com/documentation?key=Price&cat=multipleSymbolsFullPriceEndpoint
*/
export async function legacy_fetchTokensPrice(
symbols: string[],
revalidate: Revalidation = CRYPTOCOMPARE_REVALIDATION_TIMES.CURRENT_PRICE,
) {
const url = new URL('https://min-api.cryptocompare.com/data/pricemultifull')
url.searchParams.set('fsyms', symbols.join(','))
url.searchParams.set('tsyms', 'USD')
url.searchParams.set('relaxedValidation', 'true')
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
try {
const body = await _fetch<legacy_TokensPriceResponseBody>(
url,
revalidate,
'legacy_fetchTokensPrice',
)
const data = body.RAW
return data
} catch (error: unknown) {
if (
error instanceof Error &&
error.message.includes('market does not exist')
) {
console.warn('Removed failing symbols, retrying without them')
const errorMatch = error.message.match(/\((\w+)-USD\)/)
if (errorMatch && errorMatch[1]) {
const failedSymbol = errorMatch[1]
const filteredSymbols = symbols.filter(s => s !== failedSymbol)
if (filteredSymbols.length > 0) {
return await legacy_fetchTokensPrice(filteredSymbols, revalidate)
}
}
}
console.error('Failed to fetch prices:', error)
return {}
}
}
/**
* Returns the price of a tokens at a specific timestamp.
* @see https://min-api.cryptocompare.com/documentation?key=Historical&cat=dataHistoday
*/
export async function fetchTokensPriceForDate(
symbols: string[],
timestamp: number,
revalidate: Revalidation = CRYPTOCOMPARE_REVALIDATION_TIMES.PRICE_FOR_DATE,
) {
const data: Record<string, { USD: { PRICE: number } }> = {}
for (const symbol of symbols) {
try {
const url = new URL('https://min-api.cryptocompare.com/data/v2/histoday')
url.searchParams.set('fsym', symbol)
url.searchParams.set('tsym', 'USD')
url.searchParams.set('toTs', timestamp.toString())
url.searchParams.set('limit', '1')
url.searchParams.set('tryConversion', 'false')
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
const body = await _fetch<legacy_TokenPriceHistoryResponseBody>(
url,
revalidate,
'fetchTokensPriceForDate',
)
const prices = body.Data.Data
if (prices.length > 0) {
data[symbol] = {
USD: {
PRICE: prices[0].close,
},
}
}
} catch (err) {
console.warn(`No price data for ${symbol}:`, String(err))
}
}
return data
}
async function _fetch<
T extends
| legacy_TokensPriceResponseBody
| deprecated_TokensMetadataResponseBody
| legacy_research_TokenMetadataResponseBody
| legacy_TokenPriceHistoryResponseBody
| TokenMetadataResponseBody,
>(url: URL, revalidate: number, tag: string): Promise<T> {
const response = await fetch(url, {
// why: https://nextjs.org/docs/app/building-your-application/data-fetching/fetching#reusing-data-across-multiple-functions
// why: https://github.com/vercel/next.js/issues/70946
cache: 'force-cache',
next: {
revalidate,
// todo?: revalidate on error
// @see https://github.com/vercel/next.js/discussions/57792 for vercel caching error response
tags: [tag],
},
})
if (!response.ok) {
console.error(response.statusText)
if (response.status === 429) {
const apiKey = url.searchParams.get('api_key')
if (apiKey) {
markApiKeyAsRateLimited(apiKey)
}
}
throw new Error('Failed to fetch.')
}
const body: T = await response.json()
if ('Response' in body && body.Response !== 'Success') {
console.error(body.Message)
const message = 'Message' in body ? String(body.Message).toLowerCase() : ''
if (message.includes('rate limit')) {
const apiKey = url.searchParams.get('api_key')
if (apiKey) {
markApiKeyAsRateLimited(apiKey)
}
}
throw new Error('Failed to fetch.')
}
const apiKey = url.searchParams.get('api_key')
if (apiKey) {
markApiKeyAsSuccessful(apiKey)
}
return body
}
@@ -1,775 +0,0 @@
export type legacy_TokenPriceHistoryResponseBody = {
Response: string
Message: string
HasWarning: boolean
Type: number
RateLimit: Record<string, unknown>
Data: {
Aggregated: boolean
TimeFrom: number
TimeTo: number
Data: Array<{
time: number
close: number
high: number
low: number
open: number
volumefrom: number
volumeto: number
conversionType: string
conversionSymbol: string
}>
}
}
export type TokenMetadataResponseBody = {
Data: {
ID: number
TYPE: string
ID_LEGACY: number
ID_PARENT_ASSET: number | null
ID_ASSET_ISSUER: number
SYMBOL: string
URI: string
ASSET_TYPE: string
ASSET_ISSUER_NAME: string
PARENT_ASSET_SYMBOL: string | null
CREATED_ON: number
UPDATED_ON: number
PUBLIC_NOTICE: string | null
NAME: string
LOGO_URL: string
LAUNCH_DATE: number
PREVIOUS_ASSET_SYMBOLS: string | null
ASSET_ALTERNATIVE_IDS: Array<{
NAME: string
ID: string | number
}>
ASSET_DESCRIPTION_SNIPPET: string
ASSET_DECIMAL_POINTS: number
SUPPORTED_PLATFORMS: Array<{
BLOCKCHAIN: string
TOKEN_STANDARD: string
BRIDGE_OPERATOR: string
IS_ASSET_ISSUER?: boolean
EXPLORER_URL: string
SMART_CONTRACT_ADDRESS: string
LAUNCH_DATE?: number
RETIRE_DATE?: number
TRADING_AS: string
DECIMALS: number
IS_INHERITED: boolean
}>
ASSET_CUSTODIANS: Array<{
NAME: string
}>
CONTROLLED_ADDRESSES: null
ASSET_SECURITY_METRICS: Array<{
NAME: string
OVERALL_SCORE: number
OVERALL_RANK: number
UPDATED_AT: number
}>
SUPPLY_MAX: number
SUPPLY_ISSUED: number
SUPPLY_TOTAL: number
SUPPLY_CIRCULATING: number
SUPPLY_FUTURE: number
SUPPLY_LOCKED: number
SUPPLY_BURNT: number
SUPPLY_STAKED: number
LAST_BLOCK_MINT: number
LAST_BLOCK_BURN: null
BURN_ADDRESSES: Array<{
NAME: string
BLOCKCHAIN: string
ADDRESS: string
DESCRIPTION: string
}>
LOCKED_ADDRESSES: Array<{
NAME: string
BLOCKCHAIN: string
ADDRESS: string
DESCRIPTION: string
}>
HAS_SMART_CONTRACT_CAPABILITIES: boolean
SMART_CONTRACT_SUPPORT_TYPE: string
TARGET_BLOCK_MINT: number
TARGET_BLOCK_TIME: number
LAST_BLOCK_NUMBER: number
LAST_BLOCK_TIMESTAMP: number
LAST_BLOCK_TIME: number
LAST_BLOCK_SIZE: number
LAST_BLOCK_ISSUER: string
LAST_BLOCK_TRANSACTION_FEE_TOTAL: number
LAST_BLOCK_TRANSACTION_COUNT: number
LAST_BLOCK_HASHES_PER_SECOND: number
LAST_BLOCK_DIFFICULTY: number
SUPPORTED_STANDARDS: Array<{
NAME: string
}>
LAYER_TWO_SOLUTIONS: Array<{
NAME: string
WEBSITE_URL: string
DESCRIPTION: string
CATEGORY: string
}>
PRIVACY_SOLUTIONS: Array<{
NAME: string
WEBSITE_URL: string
DESCRIPTION: string
PRIVACY_SOLUTION_FEATURES?: Array<{
NAME: string
}>
}>
CODE_REPOSITORIES: Array<{
URL: string
MAKE_3RD_PARTY_REQUEST: boolean
OPEN_ISSUES: number
CLOSED_ISSUES: number
OPEN_PULL_REQUESTS: number
CLOSED_PULL_REQUESTS: number
CONTRIBUTORS: number
FORKS: number
STARS: number
SUBSCRIBERS: number
LAST_UPDATED_TS: number
CREATED_AT: number
UPDATED_AT: number
LAST_PUSH_TS: number
CODE_SIZE_IN_BYTES: number
IS_FORK: boolean
LANGUAGE: string
FORKED_ASSET_DATA: null
}>
SUBREDDITS: Array<{
URL: string
MAKE_3RD_PARTY_REQUEST: boolean
NAME: string
CURRENT_ACTIVE_USERS: number
AVERAGE_POSTS_PER_DAY: number
AVERAGE_POSTS_PER_HOUR: number
AVERAGE_COMMENTS_PER_DAY: number
AVERAGE_COMMENTS_PER_HOUR: number
SUBSCRIBERS: number
COMMUNITY_CREATED_AT: number
LAST_UPDATED_TS: number
}>
TWITTER_ACCOUNTS: Array<{
URL: string
MAKE_3RD_PARTY_REQUEST: boolean
NAME: string
USERNAME: string
VERIFIED: boolean
VERIFIED_TYPE: string
FOLLOWING: number
FOLLOWERS: number
FAVOURITES: number
LISTS: number
STATUSES: number
ACCOUNT_CREATED_AT: number
LAST_UPDATED_TS: number
}>
DISCORD_SERVERS: Array<{
URL: string
MAKE_3RD_PARTY_REQUEST: boolean
NAME: string
TOTAL_MEMBERS: number
CURRENT_ACTIVE_USERS: number
PREMIUM_SUBSCRIBERS: number
LAST_UPDATED_TS: number
}>
TELEGRAM_GROUPS: null
OTHER_SOCIAL_NETWORKS: Array<{
NAME: string
URL: string
}>
HELD_TOKEN_SALE: boolean
TOKEN_SALES: Array<{
TOKEN_SALE_TYPE: string
TOKEN_SALE_DATE_START: number
TOKEN_SALE_DATE_END: number
TOKEN_SALE_DESCRIPTION: string
TOKEN_SALE_TEAM_MEMBERS: Array<{
TYPE: string
FULL_NAME: string
}>
TOKEN_SALE_WEBSITE_URL: string
TOKEN_SALE_SUPPLY: number
TOKEN_SUPPLY_POST_SALE: string
TOKEN_SALE_PAYMENT_METHOD_TYPE: string
TOKEN_SALE_START_PRICE: number
TOKEN_SALE_START_PRICE_CURRENCY: string
TOKEN_SALE_FUNDS_RAISED: Array<{
CURRENCY: string
TOTAL_VALUE: number
}>
TOKEN_SALE_FUNDS_RAISED_USD: number
TOKEN_SALE_INVESTORS_SPLIT: Array<{
CATEGORY: string
TOTAL_TOKENS: number
}>
TOKEN_SALE_RESERVE_SPLIT: Array<{
CATEGORY: string
TOTAL_TOKENS: number
}>
TOKEN_SALE_JURISDICTIONS: Array<{
NAME: string
}>
TOKEN_SALE_REGULATORY_FRAMEWORKS: Array<{
NAME: string
}>
TOKEN_SALE_LEGAL_ADVISERS: Array<{
NAME: string
}>
TOKEN_SALE_LEGAL_FORMS: Array<{
NAME: string
}>
TOKEN_SALE_SECURITY_AUDIT_COMPANIES: Array<{
NAME: string
}>
}>
HELD_EQUITY_SALE: boolean
WEBSITE_URL: string
BLOG_URL: string
WHITE_PAPER_URL: string
OTHER_DOCUMENT_URLS: Array<{
TYPE: string
VERSION?: number
URL: string
COMMENT?: string
}>
EXPLORER_ADDRESSES: Array<{
URL: string
}>
RPC_OPERATORS: Array<{
OPERATOR_NAME: string
URL: string
REQUIRES_API_KEY: boolean
DOCUMENTATION_URL: string
API_KEY_PARAMETER_LOCATION?: string
}>
IS_EXCLUDED_FROM_MKT_CAP_TOPLIST: null
ASSET_INDUSTRIES: Array<{
ASSET_INDUSTRY: string
JUSTIFICATION: string
}>
CONSENSUS_MECHANISMS: Array<{
NAME: string
}>
CONSENSUS_ALGORITHM_TYPES: Array<{
NAME: string
DESCRIPTION: string
}>
HASHING_ALGORITHM_TYPES: Array<{
NAME: string
}>
PRICE_USD: number
PRICE_USD_SOURCE: string
PRICE_USD_LAST_UPDATE_TS: number
MKT_CAP_PENALTY: number
CIRCULATING_MKT_CAP_USD: number
TOTAL_MKT_CAP_USD: number
SPOT_MOVING_24_HOUR_QUOTE_VOLUME_TOP_TIER_DIRECT_USD: number
SPOT_MOVING_24_HOUR_QUOTE_VOLUME_DIRECT_USD: number
SPOT_MOVING_24_HOUR_QUOTE_VOLUME_TOP_TIER_USD: number
SPOT_MOVING_24_HOUR_QUOTE_VOLUME_USD: number
SPOT_MOVING_7_DAY_QUOTE_VOLUME_TOP_TIER_DIRECT_USD: number
SPOT_MOVING_7_DAY_QUOTE_VOLUME_DIRECT_USD: number
SPOT_MOVING_7_DAY_QUOTE_VOLUME_TOP_TIER_USD: number
SPOT_MOVING_7_DAY_QUOTE_VOLUME_USD: number
SPOT_MOVING_30_DAY_QUOTE_VOLUME_TOP_TIER_DIRECT_USD: number
SPOT_MOVING_30_DAY_QUOTE_VOLUME_DIRECT_USD: number
SPOT_MOVING_30_DAY_QUOTE_VOLUME_TOP_TIER_USD: number
SPOT_MOVING_30_DAY_QUOTE_VOLUME_USD: number
SPOT_MOVING_24_HOUR_CHANGE_USD: number
SPOT_MOVING_24_HOUR_CHANGE_PERCENTAGE_USD: number
SPOT_MOVING_7_DAY_CHANGE_USD: number
SPOT_MOVING_7_DAY_CHANGE_PERCENTAGE_USD: number
SPOT_MOVING_30_DAY_CHANGE_USD: number
SPOT_MOVING_30_DAY_CHANGE_PERCENTAGE_USD: number
TOPLIST_BASE_RANK: {
CREATED_ON: number
LAUNCH_DATE: number
CIRCULATING_MKT_CAP_USD: number
TOTAL_MKT_CAP_USD: number
SPOT_MOVING_24_HOUR_QUOTE_VOLUME_USD: number
SPOT_MOVING_7_DAY_QUOTE_VOLUME_USD: number
SPOT_MOVING_30_DAY_QUOTE_VOLUME_USD: number
}
ASSET_DESCRIPTION: string
ASSET_DESCRIPTION_SUMMARY: string
PROJECT_LEADERS: Array<{
LEADER_TYPE: string
FULL_NAME: string
}>
ASSOCIATED_CONTACT_DETAILS: Array<{
CONTACT_MEDIUM: string
FULL_NAME: string
}>
SEO_TITLE: string
SEO_DESCRIPTION: string
}
}
export type deprecated_TokensMetadataResponseBody = {
Response: string
Message: string
Data: {
[symbol: string]: {
ID: number
TYPE: string
ID_LEGACY: number
ID_PARENT_ASSET: number | null
ID_ASSET_ISSUER: number
SYMBOL: string
URI: string
ASSET_TYPE: string
ASSET_ISSUER_NAME: string
PARENT_ASSET_SYMBOL: string | null
CREATED_ON: number
UPDATED_ON: number
PUBLIC_NOTICE: string | null
NAME: string
LOGO_URL: string
LAUNCH_DATE: number
PREVIOUS_ASSET_SYMBOLS: string | null
ASSET_ALTERNATIVE_IDS: Array<{
NAME: string
ID: string | number
}>
ASSET_DESCRIPTION_SNIPPET: string
ASSET_DECIMAL_POINTS: number
SUPPORTED_PLATFORMS: Array<{
BLOCKCHAIN: string
TOKEN_STANDARD: string
BRIDGE_OPERATOR: string
EXPLORER_URL: string
SMART_CONTRACT_ADDRESS: string
LAUNCH_DATE: number
RETIRE_DATE?: number
TRADING_AS: string
DECIMALS: number
IS_INHERITED: boolean
}>
ASSET_CUSTODIANS: Array<{
NAME: string
}>
CONTROLLED_ADDRESSES: null
ASSET_SECURITY_METRICS: Array<{
NAME: string
OVERALL_SCORE: number
OVERALL_RANK: number
UPDATED_AT: number
}>
SUPPLY_MAX: number
SUPPLY_ISSUED: number
SUPPLY_TOTAL: number
SUPPLY_CIRCULATING: number
SUPPLY_FUTURE: number
SUPPLY_LOCKED: number
SUPPLY_BURNT: number
SUPPLY_STAKED: number
LAST_BLOCK_MINT: number
LAST_BLOCK_BURN: number | null
BURN_ADDRESSES: Array<{
NAME: string
BLOCKCHAIN: string
ADDRESS: string
DESCRIPTION: string
}>
LOCKED_ADDRESSES: Array<{
NAME: string
BLOCKCHAIN: string
ADDRESS: string
DESCRIPTION: string
}>
HAS_SMART_CONTRACT_CAPABILITIES: boolean
SMART_CONTRACT_SUPPORT_TYPE: string
TARGET_BLOCK_MINT: number
TARGET_BLOCK_TIME: number
LAST_BLOCK_NUMBER: number
LAST_BLOCK_TIMESTAMP: number
LAST_BLOCK_TIME: number
LAST_BLOCK_SIZE: number
LAST_BLOCK_ISSUER: string
LAST_BLOCK_TRANSACTION_FEE_TOTAL: number
LAST_BLOCK_TRANSACTION_COUNT: number
LAST_BLOCK_HASHES_PER_SECOND: number
LAST_BLOCK_DIFFICULTY: number
SUPPORTED_STANDARDS: Array<{
NAME: string
}>
LAYER_TWO_SOLUTIONS: Array<{
NAME: string
WEBSITE_URL: string
DESCRIPTION: string
CATEGORY: string
}>
PRIVACY_SOLUTIONS: Array<{
NAME: string
WEBSITE_URL: string
DESCRIPTION: string
PRIVACY_SOLUTION_FEATURES?: Array<{
NAME: string
}>
}>
CODE_REPOSITORIES: Array<{
URL: string
MAKE_3RD_PARTY_REQUEST: boolean
OPEN_ISSUES: number
CLOSED_ISSUES: number
OPEN_PULL_REQUESTS: number
CLOSED_PULL_REQUESTS: number
CONTRIBUTORS: number
FORKS: number
STARS: number
SUBSCRIBERS: number
LAST_UPDATED_TS: number
CREATED_AT: number
UPDATED_AT: number
LAST_PUSH_TS: number
CODE_SIZE_IN_BYTES: number
IS_FORK: boolean
LANGUAGE: string
FORKED_ASSET_DATA: null
}>
SUBREDDITS: Array<{
URL: string
MAKE_3RD_PARTY_REQUEST: boolean
NAME: string
CURRENT_ACTIVE_USERS: number
AVERAGE_POSTS_PER_DAY: number
AVERAGE_POSTS_PER_HOUR: number
AVERAGE_COMMENTS_PER_DAY: number
AVERAGE_COMMENTS_PER_HOUR: number
SUBSCRIBERS: number
COMMUNITY_CREATED_AT: number
LAST_UPDATED_TS: number
}>
TWITTER_ACCOUNTS: Array<{
URL: string
MAKE_3RD_PARTY_REQUEST: boolean
NAME: string
USERNAME: string
VERIFIED: boolean
VERIFIED_TYPE: string
FOLLOWING: number
FOLLOWERS: number
FAVOURITES: number
LISTS: number
STATUSES: number
ACCOUNT_CREATED_AT: number
LAST_UPDATED_TS: number
}>
DISCORD_SERVERS: Array<{
URL: string
MAKE_3RD_PARTY_REQUEST: boolean
NAME: string
TOTAL_MEMBERS: number
CURRENT_ACTIVE_USERS: number
PREMIUM_SUBSCRIBERS: number
LAST_UPDATED_TS: number
}>
TELEGRAM_GROUPS: null
OTHER_SOCIAL_NETWORKS: Array<{
NAME: string
URL: string
}>
HELD_TOKEN_SALE: boolean
TOKEN_SALES: Array<{
TOKEN_SALE_TYPE: string
TOKEN_SALE_DATE_START: number
TOKEN_SALE_DATE_END: number
TOKEN_SALE_DESCRIPTION: string
TOKEN_SALE_TEAM_MEMBERS: Array<{
TYPE: string
FULL_NAME: string
}>
TOKEN_SALE_WEBSITE_URL: string
TOKEN_SALE_SUPPLY: number
TOKEN_SUPPLY_POST_SALE: string
TOKEN_SALE_PAYMENT_METHOD_TYPE: string
TOKEN_SALE_START_PRICE: number
TOKEN_SALE_START_PRICE_CURRENCY: string
TOKEN_SALE_FUNDS_RAISED: Array<{
CURRENCY: string
TOTAL_VALUE: number
}>
TOKEN_SALE_FUNDS_RAISED_USD: number
TOKEN_SALE_INVESTORS_SPLIT: Array<{
CATEGORY: string
TOTAL_TOKENS: number
}>
TOKEN_SALE_RESERVE_SPLIT: Array<{
CATEGORY: string
TOTAL_TOKENS: number
}>
TOKEN_SALE_JURISDICTIONS: Array<{
NAME: string
}>
TOKEN_SALE_REGULATORY_FRAMEWORKS: Array<{
NAME: string
}>
TOKEN_SALE_LEGAL_ADVISERS: Array<{
NAME: string
}>
TOKEN_SALE_LEGAL_FORMS: Array<{
NAME: string
}>
TOKEN_SALE_SECURITY_AUDIT_COMPANIES: Array<{
NAME: string
}>
}>
HELD_EQUITY_SALE: boolean
WEBSITE_URL: string
BLOG_URL: string
WHITE_PAPER_URL: string
OTHER_DOCUMENT_URLS: Array<{
TYPE: string
VERSION?: number
URL: string
COMMENT?: string
}>
EXPLORER_ADDRESSES: Array<{
URL: string
}>
RPC_OPERATORS: Array<{
OPERATOR_NAME: string
URL: string
REQUIRES_API_KEY: boolean
DOCUMENTATION_URL: string
API_KEY_PARAMETER_LOCATION?: string
}>
IS_EXCLUDED_FROM_MKT_CAP_TOPLIST: null
ASSET_INDUSTRIES: Array<{
ASSET_INDUSTRY: string
JUSTIFICATION: string
}>
CONSENSUS_MECHANISMS: Array<{
NAME: string
}>
CONSENSUS_ALGORITHM_TYPES: Array<{
NAME: string
DESCRIPTION: string
}>
HASHING_ALGORITHM_TYPES: Array<{
NAME: string
}>
PRICE_USD: number
PRICE_USD_SOURCE: string
PRICE_USD_LAST_UPDATE_TS: number
MKT_CAP_PENALTY: number
CIRCULATING_MKT_CAP_USD: number
TOTAL_MKT_CAP_USD: number
SPOT_MOVING_24_HOUR_QUOTE_VOLUME_TOP_TIER_DIRECT_USD: number
SPOT_MOVING_24_HOUR_QUOTE_VOLUME_DIRECT_USD: number
SPOT_MOVING_24_HOUR_QUOTE_VOLUME_TOP_TIER_USD: number
SPOT_MOVING_24_HOUR_QUOTE_VOLUME_USD: number
SPOT_MOVING_7_DAY_QUOTE_VOLUME_TOP_TIER_DIRECT_USD: number
SPOT_MOVING_7_DAY_QUOTE_VOLUME_DIRECT_USD: number
SPOT_MOVING_7_DAY_QUOTE_VOLUME_TOP_TIER_USD: number
SPOT_MOVING_7_DAY_QUOTE_VOLUME_USD: number
SPOT_MOVING_30_DAY_QUOTE_VOLUME_TOP_TIER_DIRECT_USD: number
SPOT_MOVING_30_DAY_QUOTE_VOLUME_DIRECT_USD: number
SPOT_MOVING_30_DAY_QUOTE_VOLUME_TOP_TIER_USD: number
SPOT_MOVING_30_DAY_QUOTE_VOLUME_USD: number
SPOT_MOVING_24_HOUR_CHANGE_USD: number
SPOT_MOVING_24_HOUR_CHANGE_PERCENTAGE_USD: number
SPOT_MOVING_7_DAY_CHANGE_USD: number
SPOT_MOVING_7_DAY_CHANGE_PERCENTAGE_USD: number
SPOT_MOVING_30_DAY_CHANGE_USD: number
SPOT_MOVING_30_DAY_CHANGE_PERCENTAGE_USD: number
TOPLIST_BASE_RANK: {
CREATED_ON: number
LAUNCH_DATE: number
CIRCULATING_MKT_CAP_USD: number
TOTAL_MKT_CAP_USD: number
SPOT_MOVING_24_HOUR_QUOTE_VOLUME_USD: number
SPOT_MOVING_7_DAY_QUOTE_VOLUME_USD: number
SPOT_MOVING_30_DAY_QUOTE_VOLUME_USD: number
}
ASSET_DESCRIPTION: string
ASSET_DESCRIPTION_SUMMARY: string
PROJECT_LEADERS: Array<{
LEADER_TYPE: string
FULL_NAME: string
}>
ASSOCIATED_CONTACT_DETAILS: Array<{
CONTACT_MEDIUM: string
FULL_NAME: string
}>
SEO_TITLE: string
SEO_DESCRIPTION: string
}
}
Err: Record<string, unknown>
}
export type legacy_research_TokenMetadataResponseBody = {
Response: string
Message: string
Data: {
[symbol: string]: {
Id: string
Url: string
ImageUrl: string
ContentCreatedOn: number
Name: string
Symbol: string
CoinName: string
FullName: string
Description: string
AssetTokenStatus: string
Algorithm: string
ProofType: string
SortOrder: string
Sponsored: boolean
Taxonomy: {
Access: string
FCA: string
FINMA: string
Industry: string
CollateralizedAsset: string
CollateralizedAssetType: string
CollateralType: string
CollateralInfo: string
}
Rating: {
Weiss: {
Rating: string
TechnologyAdoptionRating: string
MarketPerformanceRating: string
}
}
IsTrading: boolean
TotalCoinsMined: number
CirculatingSupply: number
BlockNumber: number
NetHashesPerSecond: number
BlockReward: number
BlockTime: number
AssetLaunchDate: string
AssetWhitepaperUrl: string
AssetWebsiteUrl: string
MaxSupply: number
MktCapPenalty: number
IsUsedInDefi: number
IsUsedInNft: number
PlatformType: string
DecimalPoints: number
AlgorithmType: string
}
}
RateLimit: Record<string, unknown>
HasWarning: boolean
Type: number
}
export type legacy_TokensPriceResponseBody = {
RAW: {
[symbol: string]: {
USD: {
TYPE: string
MARKET: string
FROMSYMBOL: string
TOSYMBOL: string
FLAGS: string
PRICE: number
LASTUPDATE: number
MEDIAN: number
LASTVOLUME: number
LASTVOLUMETO: number
LASTTRADEID: string
VOLUMEDAY: number
VOLUMEDAYTO: number
VOLUME24HOUR: number
VOLUME24HOURTO: number
OPENDAY: number
HIGHDAY: number
LOWDAY: number
OPEN24HOUR: number
HIGH24HOUR: number
LOW24HOUR: number
LASTMARKET: string
VOLUMEHOUR: number
VOLUMEHOURTO: number
OPENHOUR: number
HIGHHOUR: number
LOWHOUR: number
TOPTIERVOLUME24HOUR: number
TOPTIERVOLUME24HOURTO: number
CHANGE24HOUR: number
CHANGEPCT24HOUR: number
CHANGEDAY: number
CHANGEPCTDAY: number
CHANGEHOUR: number
CHANGEPCTHOUR: number
CONVERSIONTYPE: string
CONVERSIONSYMBOL: string
CONVERSIONLASTUPDATE: number
SUPPLY: number
MKTCAP: number
MKTCAPPENALTY: number
CIRCULATINGSUPPLY: number
CIRCULATINGSUPPLYMKTCAP: number
TOTALVOLUME24H: number
TOTALVOLUME24HTO: number
TOTALTOPTIERVOLUME24H: number
TOTALTOPTIERVOLUME24HTO: number
IMAGEURL: string
}
}
}
DISPLAY: {
[symbol: string]: {
USD: {
FROMSYMBOL: string
TOSYMBOL: string
MARKET: string
PRICE: string
LASTUPDATE: string
LASTVOLUME: string
LASTVOLUMETO: string
LASTTRADEID: string
VOLUMEDAY: string
VOLUMEDAYTO: string
VOLUME24HOUR: string
VOLUME24HOURTO: string
OPENDAY: string
HIGHDAY: string
LOWDAY: string
OPEN24HOUR: string
HIGH24HOUR: string
LOW24HOUR: string
LASTMARKET: string
VOLUMEHOUR: string
VOLUMEHOURTO: string
OPENHOUR: string
HIGHHOUR: string
LOWHOUR: string
TOPTIERVOLUME24HOUR: string
TOPTIERVOLUME24HOURTO: string
CHANGE24HOUR: string
CHANGEPCT24HOUR: string
CHANGEDAY: string
CHANGEPCTDAY: string
CHANGEHOUR: string
CHANGEPCTHOUR: string
CONVERSIONTYPE: string
CONVERSIONSYMBOL: string
CONVERSIONLASTUPDATE: string
SUPPLY: string
MKTCAP: string
MKTCAPPENALTY: string
CIRCULATINGSUPPLY: string
CIRCULATINGSUPPLYMKTCAP: string
TOTALVOLUME24H: string
TOTALVOLUME24HTO: string
TOTALTOPTIERVOLUME24H: string
TOTALTOPTIERVOLUME24HTO: string
IMAGEURL: string
}
}
}
}