Created
September 17, 2025 16:58
-
-
Save sarvagnakadiya/e28978b2fb55818b2f3c36e1271c7a21 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import axios from "axios"; | |
| import { Coin, GeckoTokenFetchResponse } from "../interfaces/coingecko"; | |
| import { getCoingeckoApiKey, COINGECKO_BASE_URL, ETH_ADDRESS } from "../config"; | |
| import { GeckoTerminalResponse } from "../interfaces/coingecko"; | |
| import { getCurrentNetworkConfig } from "../config/networks"; | |
| /** | |
| * Validate Coingecko API key | |
| */ | |
| function validateCoingeckoApiKey() { | |
| const apiKey = getCoingeckoApiKey(); | |
| if (!apiKey) { | |
| throw new Error( | |
| "Coingecko API key is not set. Please set it during SDK initialization.", | |
| ); | |
| } | |
| return apiKey; | |
| } | |
| /** | |
| * Calculate Levenshtein distance between two strings | |
| */ | |
| export function levenshteinDistance(a: string, b: string): number { | |
| const dp = Array.from({ length: a.length + 1 }, () => | |
| Array(b.length + 1).fill(0), | |
| ); | |
| for (let i = 0; i <= a.length; i++) dp[i][0] = i; | |
| for (let j = 0; j <= b.length; j++) dp[0][j] = j; | |
| for (let i = 1; i <= a.length; i++) { | |
| for (let j = 1; j <= b.length; j++) { | |
| dp[i][j] = Math.min( | |
| dp[i - 1][j] + 1, | |
| dp[i][j - 1] + 1, | |
| dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), | |
| ); | |
| } | |
| } | |
| return dp[a.length][b.length]; | |
| } | |
| /** | |
| * Calculate match score for a coin against search query | |
| */ | |
| function calculateScore(coin: Coin, query: string): number { | |
| const queryLower = query.toLowerCase(); | |
| // Get individual scores | |
| const symbolScore = baseScore(coin.symbol, queryLower); | |
| const nameScore = baseScore(coin.name, queryLower); | |
| const idScore = baseScore(coin.id, queryLower); | |
| // If id has a perfect match (1000), return that score immediately | |
| if (idScore === 1000) { | |
| return 1000; | |
| } | |
| // If name or symbol has a perfect match (1000), return that score | |
| if (symbolScore === 1000 || nameScore === 1000) { | |
| return 1000; | |
| } | |
| // If name or symbol has a high prefix match (900+), return that score | |
| if (symbolScore >= 900 || nameScore >= 900) { | |
| return Math.max(symbolScore, nameScore); | |
| } | |
| // For other cases, prioritize name and symbol matches | |
| const nameSymbolScore = Math.max(symbolScore, nameScore); | |
| // Consider id score more strongly | |
| if (idScore > 500) { | |
| return Math.max(nameSymbolScore, idScore); | |
| } | |
| // Only consider id score if name/symbol have decent matches | |
| if (nameSymbolScore > 500) { | |
| // Add a small bonus from id if it matches well | |
| return nameSymbolScore + (idScore > 300 ? 50 : 0); | |
| } | |
| return 0; | |
| } | |
| function baseScore(field: string, query: string): number { | |
| const fieldLower = field.toLowerCase(); | |
| const queryLower = query.toLowerCase(); | |
| // If exact match, return highest score | |
| if (fieldLower === queryLower) return 1000; | |
| // Skip tokens that start with special characters | |
| if (fieldLower.startsWith("_") || fieldLower.startsWith("-")) { | |
| return 0; | |
| } | |
| if (field.length === 1 && query.length > 1) { | |
| return 0; | |
| } | |
| // Check if the field contains the exact query as a whole word | |
| // This will match "usdt" in "bridged-usdt-base" but not in "usd-token" | |
| const exactWordMatch = new RegExp(`\\b${queryLower}\\b`); | |
| if (exactWordMatch.test(fieldLower)) { | |
| return 950; | |
| } | |
| // Check if the field contains the query as a substring | |
| if (fieldLower.includes(queryLower)) { | |
| // Give higher score if it's at the end of the field (for symbols like "ufet") | |
| if (fieldLower.endsWith(queryLower)) { | |
| return 900; | |
| } | |
| return 800; | |
| } | |
| // For symbols, check if the query is contained within the symbol | |
| if (field.length <= 10) { | |
| // Only apply this to short fields like symbols | |
| const regex = new RegExp(queryLower.split("").join(".*"), "i"); | |
| if (regex.test(fieldLower)) { | |
| return 100; | |
| } | |
| } | |
| // Split by common separators to handle protocol names better | |
| const fieldParts = fieldLower.split(/[-_]/); | |
| const queryParts = queryLower.split(/[-_]/); | |
| // Check prefix match (first part before separator) | |
| const fieldPrefix = fieldParts[0]; | |
| const queryPrefix = queryParts[0]; | |
| // If the field starts with the query, give it a very high score | |
| if (fieldPrefix.startsWith(queryPrefix)) { | |
| // Give extra bonus if it's an exact prefix match | |
| if (fieldPrefix === queryPrefix) { | |
| return 900; | |
| } | |
| return 850; | |
| } | |
| // If the query starts with the field prefix, give it a high score | |
| if (queryPrefix.startsWith(fieldPrefix)) { | |
| return 800; | |
| } | |
| // Calculate character-by-character match score for partial matches | |
| let score = 0; | |
| const minLength = Math.min(fieldPrefix.length, queryPrefix.length); | |
| // Check each character position with decreasing weight | |
| for (let i = 0; i < minLength; i++) { | |
| const weight = 1 - i / minLength; // Higher weight for earlier positions | |
| if (fieldPrefix[i] === queryPrefix[i]) { | |
| score += 100 * weight; // Perfect match at position | |
| } else if (i === 0) { | |
| // If first character doesn't match, return 0 | |
| return 0; | |
| } | |
| } | |
| // If we have a good initial match, add bonus for length similarity | |
| if (score > 0) { | |
| const lengthSimilarity = | |
| 1 - | |
| Math.abs(fieldPrefix.length - queryPrefix.length) / | |
| Math.max(fieldPrefix.length, queryPrefix.length); | |
| score += lengthSimilarity * 50; | |
| } | |
| // Only return high-quality matches | |
| return score > 300 ? score : 0; | |
| } | |
| /** | |
| * Normalize token names to handle ETH variations | |
| */ | |
| const TOKEN_NORMALIZATION_MAP: Record<string, string> = { | |
| // Ethereum | |
| eth: "ethereum", | |
| ethereum: "ethereum", | |
| ether: "ethereum", | |
| native: "ethereum", | |
| // USDC | |
| usdc: "usd-coin", | |
| "usd coin": "usd-coin", | |
| "usd-coin": "usd-coin", | |
| usdcoin: "usd-coin", | |
| // USDT | |
| usdt: "l2-standard-bridged-usdt-base", | |
| tether: "l2-standard-bridged-usdt-base", | |
| "usd tether": "l2-standard-bridged-usdt-base", | |
| "usd-tether": "l2-standard-bridged-usdt-base", | |
| // Add more mappings here... | |
| }; | |
| export function normalizeTokenName(token: string): string { | |
| const normalized = token.toLowerCase().trim(); | |
| return TOKEN_NORMALIZATION_MAP[normalized] || normalized; | |
| } | |
| /** | |
| * Fetch market data for multiple tokens at once | |
| */ | |
| export async function fetchTokensMarketData( | |
| tokenIds: string[], | |
| ): Promise<any[]> { | |
| try { | |
| const apiKey = validateCoingeckoApiKey(); | |
| const response = await axios.get( | |
| `${COINGECKO_BASE_URL}/api/v3/coins/markets`, | |
| { | |
| params: { | |
| vs_currency: "usd", | |
| ids: tokenIds.join(","), | |
| }, | |
| headers: { | |
| "x-cg-pro-api-key": apiKey, | |
| }, | |
| }, | |
| ); | |
| return response.data; | |
| } catch (error) { | |
| if (axios.isAxiosError(error)) { | |
| if (error.response?.status === 401) { | |
| throw new Error( | |
| "Invalid Coingecko API key. Please check your API key configuration.", | |
| ); | |
| } | |
| throw new Error(`Failed to fetch tokens market data: ${error.message}`); | |
| } | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Search for a coin ID using fuzzy matching | |
| */ | |
| export async function searchCoin(query: string): Promise<string[]> { | |
| const normalizedQuery = normalizeTokenName(query); | |
| // Direct return for known mappings | |
| if ( | |
| ["ethereum", "usd-coin", "l2-standard-bridged-usdt-base"].includes( | |
| normalizedQuery, | |
| ) | |
| ) { | |
| return [normalizedQuery]; | |
| } | |
| try { | |
| const apiKey = validateCoingeckoApiKey(); | |
| const response = await axios.get<Coin[]>( | |
| `${COINGECKO_BASE_URL}/api/v3/coins/list?asset_platform_id=base`, | |
| { | |
| headers: { | |
| "x-cg-pro-api-key": apiKey, | |
| }, | |
| }, | |
| ); | |
| const coins = response.data; | |
| const scored = coins | |
| .map((coin) => ({ | |
| id: coin.id, | |
| score: calculateScore(coin, normalizedQuery), | |
| })) | |
| .filter((c) => c.score > 500) | |
| .sort((a, b) => b.score - a.score) | |
| .slice(0, 3); | |
| console.log("Scored tokens:", scored); | |
| const marketData = await fetchTokensMarketData(scored.map((c) => c.id)); | |
| const marketDataMap = new Map(marketData.map((d) => [d.id, d.market_cap])); | |
| const ranked = scored | |
| .map((c) => ({ | |
| id: c.id, | |
| score: c.score, | |
| marketCap: marketDataMap.get(c.id) || 0, | |
| })) | |
| .sort((a, b) => b.marketCap - a.marketCap); | |
| console.log("Ranked tokens:", ranked); | |
| return ranked.map((c) => c.id); | |
| } catch (error) { | |
| if (axios.isAxiosError(error)) { | |
| if (error.response?.status === 401) { | |
| throw new Error( | |
| "Invalid Coingecko API key. Please check your API key configuration.", | |
| ); | |
| } | |
| throw new Error(`Failed to search coin: ${error.message}`); | |
| } | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Fetch detailed token information from CoinGecko | |
| */ | |
| export async function fetchTokenDetails( | |
| tokenId: string, | |
| ): Promise<GeckoTerminalResponse> { | |
| try { | |
| const apiKey = validateCoingeckoApiKey(); | |
| const response = await axios.get<GeckoTerminalResponse>( | |
| `${COINGECKO_BASE_URL}/api/v3/coins/${tokenId}`, | |
| { | |
| headers: { | |
| "x-cg-pro-api-key": apiKey, | |
| }, | |
| }, | |
| ); | |
| // Extract only the needed fields from the response | |
| const data = response.data; | |
| // Create a filtered response with only the necessary fields | |
| const filteredData: GeckoTerminalResponse = { | |
| symbol: data.symbol, | |
| name: data.name, | |
| detail_platforms: data.detail_platforms, | |
| categories: data.categories, | |
| description: { | |
| en: data.description?.en || "", | |
| }, | |
| image: { | |
| small: data.image?.small || "", | |
| }, | |
| market_data: { | |
| current_price: { | |
| eth: data.market_data?.current_price?.eth || 0, | |
| usd: data.market_data?.current_price?.usd || 0, | |
| }, | |
| ath: { | |
| eth: data.market_data?.ath?.eth || 0, | |
| usd: data.market_data?.ath?.usd || 0, | |
| }, | |
| atl: { | |
| eth: data.market_data?.atl?.eth || 0, | |
| usd: data.market_data?.atl?.usd || 0, | |
| }, | |
| market_cap: { | |
| eth: data.market_data?.market_cap?.eth || 0, | |
| usd: data.market_data?.market_cap?.usd || 0, | |
| }, | |
| fully_diluted_valuation: { | |
| eth: data.market_data?.fully_diluted_valuation?.eth || 0, | |
| usd: data.market_data?.fully_diluted_valuation?.usd || 0, | |
| }, | |
| total_volume: { | |
| eth: data.market_data?.total_volume?.eth || 0, | |
| usd: data.market_data?.total_volume?.usd || 0, | |
| }, | |
| high_24h: { | |
| eth: data.market_data?.high_24h?.eth || 0, | |
| usd: data.market_data?.high_24h?.usd || 0, | |
| }, | |
| low_24h: { | |
| eth: data.market_data?.low_24h?.eth || 0, | |
| usd: data.market_data?.low_24h?.usd || 0, | |
| }, | |
| price_change_percentage_24h: | |
| data.market_data?.price_change_percentage_24h || 0, | |
| }, | |
| }; | |
| return filteredData; | |
| } catch (error) { | |
| if (axios.isAxiosError(error)) { | |
| if (error.response?.status === 401) { | |
| throw new Error( | |
| "Invalid Coingecko API key. Please check your API key configuration.", | |
| ); | |
| } | |
| throw new Error(`Failed to fetch token details: ${error.message}`); | |
| } | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Fetch detailed token information from CoinGecko by contract address | |
| */ | |
| export async function fetchTokenDetailsByContract( | |
| contractAddress: string, | |
| ): Promise<GeckoTerminalResponse | null> { | |
| try { | |
| const apiKey = validateCoingeckoApiKey(); | |
| const response = await axios.get<GeckoTokenFetchResponse>( | |
| `${COINGECKO_BASE_URL}/api/v3/onchain/networks/base/tokens/${contractAddress}`, | |
| { | |
| headers: { | |
| "x-cg-pro-api-key": apiKey, | |
| }, | |
| }, | |
| ); | |
| // Extract only the needed fields from the response | |
| const data = response.data.data; | |
| // Create a filtered response with only the necessary fields | |
| const filteredData: GeckoTerminalResponse = { | |
| symbol: data.attributes.symbol, | |
| name: data.attributes.name, | |
| detail_platforms: { | |
| base: { | |
| contract_address: data.attributes.address, | |
| decimal_place: data.attributes.decimals, | |
| }, | |
| }, | |
| categories: [], | |
| description: { | |
| en: data.attributes.name || "", | |
| }, | |
| image: { | |
| small: data.attributes.image_url || "", | |
| }, | |
| market_data: { | |
| current_price: { | |
| eth: 0, | |
| usd: Number(data.attributes.price_usd) || 0, | |
| }, | |
| ath: { | |
| eth: 0, | |
| usd: 0, | |
| }, | |
| atl: { | |
| eth: 0, | |
| usd: 0, | |
| }, | |
| market_cap: { | |
| eth: 0, | |
| usd: Number(data.attributes.market_cap_usd) || 0, | |
| }, | |
| fully_diluted_valuation: { | |
| eth: 0, | |
| usd: Number(data.attributes.fdv_usd) || 0, | |
| }, | |
| total_volume: { | |
| eth: 0, | |
| usd: 0, | |
| }, | |
| high_24h: { | |
| eth: 0, | |
| usd: Number(data.attributes.volume_usd.h24) || 0, | |
| }, | |
| low_24h: { | |
| eth: 0, | |
| usd: 0, | |
| }, | |
| price_change_percentage_24h: 0, | |
| }, | |
| }; | |
| return filteredData; | |
| } catch (error) { | |
| if (axios.isAxiosError(error)) { | |
| if (error.response?.status === 401) { | |
| console.error( | |
| "Invalid Coingecko API key. Please check your API key configuration.", | |
| ); | |
| } else if (error.response?.status === 404) { | |
| console.log( | |
| `Token with contract address ${contractAddress} not found in CoinGecko database`, | |
| ); | |
| } else { | |
| console.error( | |
| `Failed to fetch token details by contract: ${error.message}`, | |
| ); | |
| } | |
| return null; | |
| } | |
| console.error( | |
| `Error fetching token details for ${contractAddress}:`, | |
| error, | |
| ); | |
| return null; | |
| } | |
| } | |
| /** | |
| * Get token address and decimals, handling ETH specially | |
| */ | |
| export function getTokenAddressAndDecimals( | |
| tokenId: string, | |
| tokenData: GeckoTerminalResponse, | |
| ): { address: string; decimals: number } { | |
| const networkConfig = getCurrentNetworkConfig(); | |
| const platformId = networkConfig.coingeckoName; | |
| const address = | |
| tokenId === "ethereum" | |
| ? ETH_ADDRESS | |
| : tokenData.detail_platforms?.[platformId]?.contract_address || | |
| ETH_ADDRESS; | |
| const decimals = | |
| tokenId === "ethereum" | |
| ? 18 | |
| : (tokenData.detail_platforms?.[platformId]?.decimal_place ?? 18); | |
| return { address, decimals }; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment