mirror of
https://github.com/status-im/status-web.git
synced 2026-08-31 06:11:10 +00:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@status-im/wallet': patch
|
||||
---
|
||||
|
||||
Refactor: Replace CRYPTOCOMPARE references with MARKET_PROXY
|
||||
@@ -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=""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
-5
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user