Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 105 additions & 27 deletions lib/usd-price.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,6 +11,7 @@
*/

import { PLOT_TOKEN } from "./contracts/constants";
import { createServiceRoleClient } from "./supabase";

// In-memory cache
let cachedPrice: number | null = null;
Expand All @@ -22,7 +24,7 @@ let inflightRequest: Promise<number | null> | 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,
Expand Down Expand Up @@ -51,38 +53,89 @@ export async function getPlotUsdPrice(
}

async function fetchPlotUsdPrice(): Promise<number | null> {
// 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<number> {
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<never>((_, 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<number> {
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<number> {
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`;
Expand All @@ -93,16 +146,41 @@ async function fetchPlotUsdPrice(): Promise<number | null> {
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<number | null> {
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;
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plotlink",
"version": "0.1.24",
"version": "0.1.25",
"private": true,
"workspaces": [
"packages/*"
Expand Down
9 changes: 8 additions & 1 deletion src/components/MarketCapBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ export function MarketCapBox({
staleTime: 60000,
});

if (!plotUsd) return null;
if (!plotUsd) {
return (
<>
<div className="text-foreground text-sm font-bold">—</div>
<div className="text-muted text-[9px]">Market Cap</div>
</>
);
}

const marketCapUsd = totalSupply * pricePerToken * plotUsd;
const changePercent = priceChange?.changePercent ?? null;
Expand Down
Loading