diff --git a/lib/usd-price.ts b/lib/usd-price.ts new file mode 100644 index 00000000..dafef90b --- /dev/null +++ b/lib/usd-price.ts @@ -0,0 +1,119 @@ +/** + * USD Price for PLOT token (server-side) + * + * Fallback chain: Mint Club SDK → GeckoTerminal → CoinGecko → DB cache + * + * Only tracks PLOT USD price — storyline token USD values are derived from it: + * storyline_token_USD = storyline_token_price_in_PLOT × PLOT_USD_price + * + * Reference: ~/Projects/dropcast/lib/usd-price.ts + */ + +import { PLOT_TOKEN } from "./contracts/constants"; + +// In-memory cache +let cachedPrice: number | null = null; +let cacheTimestamp = 0; +const CACHE_TTL = 2 * 60 * 1000; // 2 minutes + +// In-flight coalescing +let inflightRequest: Promise | null = null; + +const PLOT_ADDRESS = PLOT_TOKEN.toLowerCase(); + +/** + * Get PLOT token USD price with fallback chain + */ +export async function getPlotUsdPrice( + forceRefresh = false, +): Promise { + // Return cached price if fresh + if (!forceRefresh && cachedPrice !== null && Date.now() - cacheTimestamp < CACHE_TTL) { + return cachedPrice; + } + + // Coalesce concurrent requests + if (inflightRequest && !forceRefresh) { + return inflightRequest; + } + + inflightRequest = fetchPlotUsdPrice(); + try { + const price = await inflightRequest; + if (price !== null) { + cachedPrice = price; + cacheTimestamp = Date.now(); + } + return price ?? cachedPrice; + } finally { + inflightRequest = null; + } +} + +async function fetchPlotUsdPrice(): Promise { + // Source 1: Mint Club SDK (optional dependency — skipped if not installed) + 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; + } + } catch { + console.info(`[USD Price] source=mint_club result=miss token=${PLOT_ADDRESS}`); + } + + // Source 2: GeckoTerminal (free, no key required) + 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; + } + } + } catch { + console.info(`[USD Price] source=geckoterminal result=miss token=${PLOT_ADDRESS}`); + } + + // Source 3: CoinGecko + 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`; + const headers: HeadersInit = { Accept: "application/json" }; + if (apiKey) headers["x-cg-demo-api-key"] = apiKey; + + const response = await fetch(url, { + 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; + } + } catch { + console.info(`[USD Price] source=coingecko result=miss token=${PLOT_ADDRESS}`); + } + + console.warn(`[USD Price] All sources exhausted for PLOT token`); + return null; +} + +/** + * Format a USD value for display + */ +export function formatUsdValue(value: number | null): string { + if (value === null) return "—"; + if (value < 0.01) return "< $0.01"; + if (value < 1) return `$${value.toFixed(3)}`; + if (value < 1000) return `$${value.toFixed(2)}`; + if (value < 1_000_000) return `$${(value / 1000).toFixed(2)}K`; + return `$${(value / 1_000_000).toFixed(2)}M`; +} diff --git a/package.json b/package.json index eaf0be55..c2368d4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.4", + "version": "0.1.5", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/api/tokens/plot-price/route.ts b/src/app/api/tokens/plot-price/route.ts new file mode 100644 index 00000000..5f532917 --- /dev/null +++ b/src/app/api/tokens/plot-price/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getPlotUsdPrice } from "../../../../../lib/usd-price"; + +export const revalidate = 120; // ISR: revalidate every 2 minutes + +export async function GET() { + const price = await getPlotUsdPrice(); + return NextResponse.json( + { price, timestamp: Date.now() }, + { + headers: { + "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300", + }, + }, + ); +} diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index ee6dd4f3..15b5e5ba 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -15,9 +15,12 @@ import { getTokenPrice, mcv2BondAbi, erc20Abi, type TokenPriceInfo } from "../.. import { browserClient } from "../../../../lib/rpc"; import type { FarcasterProfile } from "../../../../lib/farcaster"; import type { AgentMetadata } from "../../../../lib/contracts/erc8004"; +import { usePlotUsdPrice } from "../../../hooks/usePlotUsdPrice"; +import { formatUsdValue } from "../../../../lib/usd-price"; type Tab = "stories" | "portfolio" | "activity"; + export default function ProfilePage() { const params = useParams<{ address: string }>(); const address = params.address.toLowerCase(); @@ -576,6 +579,8 @@ interface PortfolioHolding { } function PortfolioTab({ address }: { address: string }) { + const { data: plotUsd } = usePlotUsdPrice(); + // Fetch on-chain token holdings const { data: holdings, isLoading: holdingsLoading } = useQuery({ queryKey: ["profile-holdings", address], @@ -749,6 +754,11 @@ function PortfolioTab({ address }: { address: string }) { {formatPrice(formatUnits(totalValue, 18))} {RESERVE_LABEL} + {plotUsd && ( + + ≈ {formatUsdValue(Number(formatUnits(totalValue, 18)) * plotUsd)} + + )} across {holdings!.length} {holdings!.length === 1 ? "token" : "tokens"} @@ -776,9 +786,16 @@ function PortfolioTab({ address }: { address: string }) { )} - - {formatPrice(formatUnits(h.value, 18))} {RESERVE_LABEL} - +
+ + {formatPrice(formatUnits(h.value, 18))} {RESERVE_LABEL} + + {plotUsd && ( + + ({formatUsdValue(Number(formatUnits(h.value, 18)) * plotUsd)}) + + )} +
diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index eb74791c..f6b6244a 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -20,6 +20,7 @@ import { WriterIdentity } from "../../../components/WriterIdentity"; import { ViewCount, ViewTracker } from "../../../components/ViewCount"; import { CommentSection } from "../../../components/CommentSection"; import { MobileActionBar } from "../../../components/MobileActionBar"; +import { UsdPriceTag } from "../../../components/UsdPriceTag"; type Params = Promise<{ storylineId: string }>; @@ -263,6 +264,7 @@ function StoryHeader({ {formatPrice(priceInfo.pricePerToken)} {reserveLabel} +
diff --git a/src/components/StoryCardStats.tsx b/src/components/StoryCardStats.tsx index facc7a45..6d748fe9 100644 --- a/src/components/StoryCardStats.tsx +++ b/src/components/StoryCardStats.tsx @@ -6,6 +6,8 @@ import { getTokenTVL, getTokenPrice } from "../../lib/price"; import { browserClient } from "../../lib/rpc"; import { RESERVE_LABEL } from "../../lib/contracts/constants"; import { useBatchTokenData } from "./BatchTokenDataProvider"; +import { usePlotUsdPrice } from "../hooks/usePlotUsdPrice"; +import { formatUsdValue } from "../../lib/usd-price"; function formatCompact(value: string): string { const num = parseFloat(value); @@ -16,9 +18,11 @@ function formatCompact(value: string): string { return num.toFixed(2); } + /** Full stats row with price + TVL (used on detail pages) */ export function StoryCardStats({ tokenAddress }: { tokenAddress: string }) { const addr = tokenAddress as Address; + const { data: plotUsd } = usePlotUsdPrice(); const { data: priceInfo } = useQuery({ queryKey: ["card-price", tokenAddress], @@ -39,10 +43,17 @@ export function StoryCardStats({ tokenAddress }: { tokenAddress: string }) { ? formatCompact(tvlData.tvl) : "—"; + const priceUsd = priceInfo && plotUsd + ? formatUsdValue(parseFloat(priceInfo.pricePerToken) * plotUsd) + : null; + const tvlUsd = tvlData && plotUsd + ? formatUsdValue(parseFloat(tvlData.tvl) * plotUsd) + : null; + return (
- Price: {price} {RESERVE_LABEL} - TVL: {tvl} {RESERVE_LABEL} + Price: {price} {RESERVE_LABEL}{priceUsd && ({priceUsd})} + TVL: {tvl} {RESERVE_LABEL}{tvlUsd && ({tvlUsd})}
); } @@ -52,6 +63,7 @@ export function StoryCardStats({ tokenAddress }: { tokenAddress: string }) { export function StoryCardTVL({ tokenAddress }: { tokenAddress: string }) { const { entry: batchEntry, isReady } = useBatchTokenData(tokenAddress); const addr = tokenAddress as Address; + const { data: plotUsd } = usePlotUsdPrice(); // Only fall back to individual fetch AFTER batch has settled const { data: individualTvl } = useQuery({ @@ -63,8 +75,11 @@ export function StoryCardTVL({ tokenAddress }: { tokenAddress: string }) { const tvlData = batchEntry?.tvl ?? individualTvl; const tvl = tvlData ? formatCompact(tvlData.tvl) : "—"; + const tvlUsd = tvlData && plotUsd + ? formatUsdValue(parseFloat(tvlData.tvl) * plotUsd) + : null; return ( - TVL: {tvl} {RESERVE_LABEL} + TVL: {tvl} {RESERVE_LABEL}{tvlUsd && ({tvlUsd})} ); } diff --git a/src/components/UsdPriceTag.tsx b/src/components/UsdPriceTag.tsx new file mode 100644 index 00000000..0ad706a8 --- /dev/null +++ b/src/components/UsdPriceTag.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { usePlotUsdPrice } from "../hooks/usePlotUsdPrice"; +import { formatUsdValue } from "../../lib/usd-price"; + +/** + * Inline USD price tag that converts a PLOT-denominated value to USD. + * Renders nothing while loading or if price is unavailable. + */ +export function UsdPriceTag({ plotAmount }: { plotAmount: number }) { + const { data: plotUsd } = usePlotUsdPrice(); + if (!plotUsd || plotAmount <= 0) return null; + + const usd = plotAmount * plotUsd; + return ({formatUsdValue(usd)}); +} diff --git a/src/hooks/usePlotUsdPrice.ts b/src/hooks/usePlotUsdPrice.ts new file mode 100644 index 00000000..db707f19 --- /dev/null +++ b/src/hooks/usePlotUsdPrice.ts @@ -0,0 +1,22 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +/** + * Client hook to get PLOT USD price from the API route. + * Caches for 2 minutes, auto-refetches every 2 minutes. + */ +export function usePlotUsdPrice() { + return useQuery({ + queryKey: ["plot-usd-price"], + queryFn: async () => { + const res = await fetch("/api/tokens/plot-price"); + if (!res.ok) return null; + const data = await res.json(); + return data.price as number | null; + }, + staleTime: 2 * 60 * 1000, + refetchInterval: 2 * 60 * 1000, + retry: 2, + }); +}