rspace-online/modules/rwallet/lib/price-feed.ts

222 lines
6.3 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;
cgAvailable: boolean; // true if CoinGecko successfully returned token data
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<{ prices: Map<string, number>; available: boolean }> {
const platform = CHAIN_PLATFORM[chainId];
if (!platform || addresses.length === 0) return { prices: new Map(), available: false };
const lower = [...new Set(addresses.map((a) => a.toLowerCase()))];
const prices = new Map<string, number>();
const data = await cgFetch(
`https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${lower.join(",")}&vs_currencies=usd`,
);
if (data && !data.error_code) {
for (const addr of lower) {
if (data[addr]?.usd) prices.set(addr, data[addr].usd);
}
return { prices, available: true };
}
return { prices, available: false };
}
/** 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, tokenResult] = await Promise.all([
getNativePrice(chainId),
getTokenPrices(chainId, tokenAddresses),
]);
const entry: CacheEntry = {
prices: tokenResult.prices,
nativePrice,
cgAvailable: tokenResult.available,
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),
};
});
// Only filter spam when CoinGecko data is available to verify against
if (options?.filterSpam && priceData.cgAvailable) {
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;
}
}