diff --git a/lib/usd-price.ts b/lib/usd-price.ts index 6dbe11ff..1b493808 100644 --- a/lib/usd-price.ts +++ b/lib/usd-price.ts @@ -1,7 +1,8 @@ /** * USD Price for PLOT token (server-side) * - * Fallback chain: Mint Club SDK → GeckoTerminal → CoinGecko → DB cache + * Parallel fetch: Mint Club SDK | GeckoTerminal | CoinGecko (Promise.any) + * Fallback: in-memory cache → DB cache (trade_history.reserve_usd_rate) * * Only tracks PLOT USD price — storyline token USD values are derived from it: * storyline_token_USD = storyline_token_price_in_PLOT × PLOT_USD_price @@ -10,6 +11,7 @@ */ import { PLOT_TOKEN } from "./contracts/constants"; +import { createServiceRoleClient } from "./supabase"; // In-memory cache let cachedPrice: number | null = null; @@ -22,7 +24,7 @@ let inflightRequest: Promise | null = null; const PLOT_ADDRESS = PLOT_TOKEN.toLowerCase(); /** - * Get PLOT token USD price with fallback chain + * Get PLOT token USD price with parallel sources + DB fallback */ export async function getPlotUsdPrice( forceRefresh = false, @@ -51,38 +53,89 @@ export async function getPlotUsdPrice( } async function fetchPlotUsdPrice(): Promise { - // Source 1: Mint Club SDK (optional dependency — skipped if not installed) + const start = Date.now(); + + // Try all external sources in parallel — use whichever responds first try { - const { mintclub } = await import(/* webpackIgnore: true */ "mint.club-v2-sdk" as string) as { mintclub: { network: (n: string) => { token: (a: `0x${string}`) => { getUsdRate: () => Promise<{ usdRate: number }> } } } }; - const token = mintclub.network("base").token(PLOT_TOKEN); - const { usdRate } = await token.getUsdRate(); - if (usdRate && usdRate > 0) { - return usdRate; - } + const price = await Promise.any([ + fetchFromMintClub(), + fetchFromGeckoTerminal(), + fetchFromCoinGecko(), + ]); + console.info(`[USD Price] result=hit price=${price} elapsed=${Date.now() - start}ms`); + return price; } catch { - console.info(`[USD Price] source=mint_club result=miss token=${PLOT_ADDRESS}`); + // All sources failed — AggregateError + console.warn(`[USD Price] All external sources failed, elapsed=${Date.now() - start}ms`); + } + + // Fallback: last known price from trade_history DB + const dbPrice = await fetchFromDb(); + if (dbPrice !== null) { + console.info(`[USD Price] result=db_fallback price=${dbPrice}`); + return dbPrice; + } + + console.warn(`[USD Price] All sources exhausted for PLOT token`); + return null; +} + +/** Mint Club SDK — on-chain RPC call (with 3s timeout to match other sources) */ +async function fetchFromMintClub(): Promise { + const start = Date.now(); + try { + const result = await Promise.race([ + (async () => { + const { mintclub } = await import(/* webpackIgnore: true */ "mint.club-v2-sdk" as string) as { mintclub: { network: (n: string) => { token: (a: `0x${string}`) => { getUsdRate: () => Promise<{ usdRate: number }> } } } }; + const token = mintclub.network("base").token(PLOT_TOKEN); + return token.getUsdRate(); + })(), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timeout")), 3000), + ), + ]); + if (result.usdRate && result.usdRate > 0) { + console.info(`[USD Price] source=mint_club result=hit elapsed=${Date.now() - start}ms`); + return result.usdRate; + } + throw new Error("invalid rate"); + } catch (e) { + const reason = e instanceof Error ? e.message : "unknown"; + console.info(`[USD Price] source=mint_club result=miss reason=${reason} elapsed=${Date.now() - start}ms`); } + throw new Error("mint_club failed"); +} - // Source 2: GeckoTerminal (free, no key required) +/** GeckoTerminal — free HTTP API */ +async function fetchFromGeckoTerminal(): Promise { + const start = Date.now(); try { const url = `https://api.geckoterminal.com/api/v2/networks/base/tokens/${PLOT_ADDRESS}`; const response = await fetch(url, { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(3000), }); - if (response.ok) { - const data = await response.json(); - const priceUsd = data?.data?.attributes?.price_usd; - if (priceUsd) { - const price = parseFloat(priceUsd); - if (!isNaN(price) && price > 0) return price; + if (!response.ok) throw new Error(`http_${response.status}`); + const data = await response.json(); + const priceUsd = data?.data?.attributes?.price_usd; + if (priceUsd) { + const price = parseFloat(priceUsd); + if (!isNaN(price) && price > 0) { + console.info(`[USD Price] source=geckoterminal result=hit elapsed=${Date.now() - start}ms`); + return price; } } - } catch { - console.info(`[USD Price] source=geckoterminal result=miss token=${PLOT_ADDRESS}`); + throw new Error("no_price_data"); + } catch (e) { + const reason = e instanceof Error ? e.message : "unknown"; + console.info(`[USD Price] source=geckoterminal result=miss reason=${reason} elapsed=${Date.now() - start}ms`); } + throw new Error("geckoterminal failed"); +} - // Source 3: CoinGecko +/** CoinGecko — HTTP API (optional key) */ +async function fetchFromCoinGecko(): Promise { + const start = Date.now(); try { const apiKey = process.env.COINGECKO_API_KEY; const url = `https://api.coingecko.com/api/v3/simple/token_price/base?contract_addresses=${PLOT_ADDRESS}&vs_currencies=usd`; @@ -93,16 +146,41 @@ async function fetchPlotUsdPrice(): Promise { headers, signal: AbortSignal.timeout(3000), }); - if (response.ok) { - const data = await response.json(); - const tokenData = data[PLOT_ADDRESS]; - if (tokenData?.usd && tokenData.usd > 0) return tokenData.usd; + if (!response.ok) throw new Error(`http_${response.status}`); + const data = await response.json(); + const tokenData = data[PLOT_ADDRESS]; + if (tokenData?.usd && tokenData.usd > 0) { + console.info(`[USD Price] source=coingecko result=hit elapsed=${Date.now() - start}ms`); + return tokenData.usd; } - } catch { - console.info(`[USD Price] source=coingecko result=miss token=${PLOT_ADDRESS}`); + throw new Error("no_price_data"); + } catch (e) { + const reason = e instanceof Error ? e.message : "unknown"; + console.info(`[USD Price] source=coingecko result=miss reason=${reason} elapsed=${Date.now() - start}ms`); } + throw new Error("coingecko failed"); +} - console.warn(`[USD Price] All sources exhausted for PLOT token`); +/** DB fallback: latest reserve_usd_rate from trade_history (survives cold starts) */ +async function fetchFromDb(): Promise { + try { + const supabase = createServiceRoleClient(); + if (!supabase) return null; + + const { data } = await supabase + .from("trade_history") + .select("reserve_usd_rate") + .not("reserve_usd_rate", "is", null) + .order("block_timestamp", { ascending: false }) + .limit(1) + .single(); + + if (data?.reserve_usd_rate && data.reserve_usd_rate > 0) { + return data.reserve_usd_rate; + } + } catch { + console.info(`[USD Price] source=db result=miss`); + } return null; } diff --git a/package.json b/package.json index a61a0560..2309be32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.24", + "version": "0.1.25", "private": true, "workspaces": [ "packages/*" diff --git a/src/components/MarketCapBox.tsx b/src/components/MarketCapBox.tsx index 2c231d51..517fb6da 100644 --- a/src/components/MarketCapBox.tsx +++ b/src/components/MarketCapBox.tsx @@ -27,7 +27,14 @@ export function MarketCapBox({ staleTime: 60000, }); - if (!plotUsd) return null; + if (!plotUsd) { + return ( + <> +
+
Market Cap
+ + ); + } const marketCapUsd = totalSupply * pricePerToken * plotUsd; const changePercent = priceChange?.changePercent ?? null;