218 lines
5.9 KiB
TypeScript
218 lines
5.9 KiB
TypeScript
/**
|
|
* CoinGecko price feed with 5-minute in-memory cache.
|
|
* Provides USD prices for native coins and ERC-20 tokens.
|
|
*/
|
|
|
|
// CoinGecko chain ID → platform ID mapping
|
|
const CHAIN_PLATFORM: Record<string, string> = {
|
|
"1": "ethereum",
|
|
"10": "optimistic-ethereum",
|
|
"100": "xdai",
|
|
"137": "polygon-pos",
|
|
"8453": "base",
|
|
"42161": "arbitrum-one",
|
|
"56": "binance-smart-chain",
|
|
"43114": "avalanche",
|
|
"42220": "celo",
|
|
"324": "zksync",
|
|
};
|
|
|
|
// CoinGecko native coin IDs per chain
|
|
const NATIVE_COIN_ID: Record<string, string> = {
|
|
"1": "ethereum",
|
|
"10": "ethereum",
|
|
"100": "dai",
|
|
"137": "matic-network",
|
|
"8453": "ethereum",
|
|
"42161": "ethereum",
|
|
"56": "binancecoin",
|
|
"43114": "avalanche-2",
|
|
"42220": "celo",
|
|
"324": "ethereum",
|
|
};
|
|
|
|
interface CacheEntry {
|
|
prices: Map<string, number>; // address (lowercase) → USD price
|
|
nativePrice: number;
|
|
ts: number;
|
|
}
|
|
|
|
const TTL = 5 * 60 * 1000; // 5 minutes
|
|
const cache = new Map<string, CacheEntry>();
|
|
const inFlight = new Map<string, Promise<CacheEntry>>();
|
|
|
|
async function cgFetch(url: string): Promise<any> {
|
|
const res = await fetch(url, {
|
|
headers: { accept: "application/json" },
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (res.status === 429) {
|
|
console.warn("[price-feed] CoinGecko rate limited, waiting 60s...");
|
|
await new Promise((r) => setTimeout(r, 60000));
|
|
const retry = await fetch(url, {
|
|
headers: { accept: "application/json" },
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (!retry.ok) return null;
|
|
return retry.json();
|
|
}
|
|
if (!res.ok) return null;
|
|
return res.json();
|
|
}
|
|
|
|
/** Fetch native coin price for a chain */
|
|
export async function getNativePrice(chainId: string): Promise<number> {
|
|
const entry = cache.get(chainId);
|
|
if (entry && Date.now() - entry.ts < TTL) return entry.nativePrice;
|
|
|
|
const coinId = NATIVE_COIN_ID[chainId];
|
|
if (!coinId) return 0;
|
|
|
|
const data = await cgFetch(
|
|
`https://api.coingecko.com/api/v3/simple/price?ids=${coinId}&vs_currencies=usd`,
|
|
);
|
|
return data?.[coinId]?.usd ?? 0;
|
|
}
|
|
|
|
/** Fetch token prices for a batch of contract addresses on a chain */
|
|
export async function getTokenPrices(
|
|
chainId: string,
|
|
addresses: string[],
|
|
): Promise<Map<string, number>> {
|
|
const platform = CHAIN_PLATFORM[chainId];
|
|
if (!platform || addresses.length === 0) return new Map();
|
|
|
|
const lower = addresses.map((a) => a.toLowerCase());
|
|
const data = await cgFetch(
|
|
`https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${lower.join(",")}&vs_currencies=usd`,
|
|
);
|
|
|
|
const result = new Map<string, number>();
|
|
if (data) {
|
|
for (const addr of lower) {
|
|
if (data[addr]?.usd) result.set(addr, data[addr].usd);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Fetch and cache all prices for a chain (native + tokens) */
|
|
async function fetchChainPrices(
|
|
chainId: string,
|
|
tokenAddresses: string[],
|
|
): Promise<CacheEntry> {
|
|
const existing = cache.get(chainId);
|
|
if (existing && Date.now() - existing.ts < TTL) return existing;
|
|
|
|
// Deduplicate concurrent requests for same chain
|
|
const key = chainId;
|
|
const pending = inFlight.get(key);
|
|
if (pending) return pending;
|
|
|
|
const promise = (async (): Promise<CacheEntry> => {
|
|
try {
|
|
const [nativePrice, tokenPrices] = await Promise.all([
|
|
getNativePrice(chainId),
|
|
getTokenPrices(chainId, tokenAddresses),
|
|
]);
|
|
const entry: CacheEntry = {
|
|
prices: tokenPrices,
|
|
nativePrice,
|
|
ts: Date.now(),
|
|
};
|
|
cache.set(chainId, entry);
|
|
return entry;
|
|
} finally {
|
|
inFlight.delete(key);
|
|
}
|
|
})();
|
|
|
|
inFlight.set(key, promise);
|
|
return promise;
|
|
}
|
|
|
|
interface BalanceItem {
|
|
tokenAddress: string | null;
|
|
token: { name: string; symbol: string; decimals: number };
|
|
balance: string;
|
|
fiatBalance: string;
|
|
fiatConversion: string;
|
|
}
|
|
|
|
/**
|
|
* Enrich balance items with USD prices from CoinGecko.
|
|
* Fills in fiatBalance and fiatConversion for items that have "0" values.
|
|
*/
|
|
export async function enrichWithPrices(
|
|
balances: BalanceItem[],
|
|
chainId: string,
|
|
options?: { filterSpam?: boolean },
|
|
): Promise<BalanceItem[]> {
|
|
// Skip testnets and unsupported chains — no CoinGecko data to verify against
|
|
if (!CHAIN_PLATFORM[chainId] && !NATIVE_COIN_ID[chainId]) return balances;
|
|
|
|
// Check if any balance actually needs pricing
|
|
const needsPricing = balances.some(
|
|
(b) =>
|
|
BigInt(b.balance || "0") > 0n &&
|
|
(b.fiatBalance === "0" || b.fiatBalance === "" || !b.fiatBalance),
|
|
);
|
|
if (!needsPricing) return balances;
|
|
|
|
const tokenAddresses = balances
|
|
.filter((b) => b.tokenAddress && b.tokenAddress !== "0x0000000000000000000000000000000000000000")
|
|
.map((b) => b.tokenAddress!);
|
|
|
|
try {
|
|
const priceData = await fetchChainPrices(chainId, tokenAddresses);
|
|
|
|
const enriched = balances.map((b) => {
|
|
// Skip if already has a real fiat value
|
|
if (b.fiatBalance && b.fiatBalance !== "0" && parseFloat(b.fiatBalance) > 0) {
|
|
return b;
|
|
}
|
|
|
|
const balWei = BigInt(b.balance || "0");
|
|
if (balWei === 0n) return b;
|
|
|
|
let price = 0;
|
|
if (!b.tokenAddress || b.tokenAddress === "0x0000000000000000000000000000000000000000") {
|
|
// Native token
|
|
price = priceData.nativePrice;
|
|
} else {
|
|
price = priceData.prices.get(b.tokenAddress.toLowerCase()) ?? 0;
|
|
}
|
|
|
|
if (price === 0) return b;
|
|
|
|
const decimals = b.token?.decimals ?? 18;
|
|
const balHuman = Number(balWei) / Math.pow(10, decimals);
|
|
const fiatValue = balHuman * price;
|
|
|
|
return {
|
|
...b,
|
|
fiatConversion: String(price),
|
|
fiatBalance: String(fiatValue),
|
|
};
|
|
});
|
|
|
|
if (options?.filterSpam) {
|
|
return enriched.filter((b) => {
|
|
// Native tokens always pass
|
|
if (!b.tokenAddress || b.tokenAddress === "0x0000000000000000000000000000000000000000") return true;
|
|
// CoinGecko recognized this token
|
|
if (priceData.prices.has(b.tokenAddress.toLowerCase())) return true;
|
|
// Safe API independently valued it at >= $1
|
|
if (parseFloat(b.fiatBalance || "0") >= 1) return true;
|
|
// Unknown ERC-20 with no verified value = spam
|
|
return false;
|
|
});
|
|
}
|
|
|
|
return enriched;
|
|
} catch (e) {
|
|
console.warn(`[price-feed] Failed to enrich prices for chain ${chainId}:`, e);
|
|
return balances;
|
|
}
|
|
}
|