From b562a255387ae82fe1a84ab2fd3dd56fc68830cb Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 25 Mar 2026 08:39:58 +0000 Subject: [PATCH 1/4] [#517] Add USD price tracking for PLOT token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create lib/usd-price.ts with fallback chain: Mint Club SDK → GeckoTerminal → CoinGecko - 2-minute in-memory cache with in-flight request coalescing - Create /api/tokens/plot-price API route (2-min ISR + CDN cache) - Create usePlotUsdPrice client hook - Create UsdPriceTag component for server-rendered pages - Display USD values on: - Story cards (price + TVL with USD in parentheses) - Story page header (token price with USD) - Profile portfolio (total value + per-token values with USD) - Bump version to 0.1.5 Fixes #517 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/usd-price.ts | 119 +++++++++++++++++++++++++ package.json | 2 +- src/app/api/tokens/plot-price/route.ts | 16 ++++ src/app/profile/[address]/page.tsx | 29 +++++- src/app/story/[storylineId]/page.tsx | 2 + src/components/StoryCardStats.tsx | 27 +++++- src/components/UsdPriceTag.tsx | 22 +++++ src/hooks/usePlotUsdPrice.ts | 22 +++++ 8 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 lib/usd-price.ts create mode 100644 src/app/api/tokens/plot-price/route.ts create mode 100644 src/components/UsdPriceTag.tsx create mode 100644 src/hooks/usePlotUsdPrice.ts 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..e632a7dc 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -15,9 +15,18 @@ 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"; type Tab = "stories" | "portfolio" | "activity"; +function formatPortfolioUsd(value: number): string { + 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`; +} + export default function ProfilePage() { const params = useParams<{ address: string }>(); const address = params.address.toLowerCase(); @@ -576,6 +585,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 +760,11 @@ function PortfolioTab({ address }: { address: string }) { {formatPrice(formatUnits(totalValue, 18))} {RESERVE_LABEL} + {plotUsd && ( + + ≈ {formatPortfolioUsd(Number(formatUnits(totalValue, 18)) * plotUsd)} + + )} across {holdings!.length} {holdings!.length === 1 ? "token" : "tokens"} @@ -776,9 +792,16 @@ function PortfolioTab({ address }: { address: string }) { )} - - {formatPrice(formatUnits(h.value, 18))} {RESERVE_LABEL} - +
+ + {formatPrice(formatUnits(h.value, 18))} {RESERVE_LABEL} + + {plotUsd && ( + + ({formatPortfolioUsd(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..2567b822 100644 --- a/src/components/StoryCardStats.tsx +++ b/src/components/StoryCardStats.tsx @@ -6,6 +6,7 @@ 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"; function formatCompact(value: string): string { const num = parseFloat(value); @@ -16,9 +17,18 @@ function formatCompact(value: string): string { return num.toFixed(2); } +function formatUsd(value: number): string { + 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`; +} + /** 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 +49,17 @@ export function StoryCardStats({ tokenAddress }: { tokenAddress: string }) { ? formatCompact(tvlData.tvl) : "—"; + const priceUsd = priceInfo && plotUsd + ? formatUsd(parseFloat(priceInfo.pricePerToken) * plotUsd) + : null; + const tvlUsd = tvlData && plotUsd + ? formatUsd(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 +69,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 +81,11 @@ export function StoryCardTVL({ tokenAddress }: { tokenAddress: string }) { const tvlData = batchEntry?.tvl ?? individualTvl; const tvl = tvlData ? formatCompact(tvlData.tvl) : "—"; + const tvlUsd = tvlData && plotUsd + ? formatUsd(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..ad1b381a --- /dev/null +++ b/src/components/UsdPriceTag.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { usePlotUsdPrice } from "../hooks/usePlotUsdPrice"; + +/** + * 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; + let formatted: string; + if (usd < 0.01) formatted = "< $0.01"; + else if (usd < 1) formatted = `$${usd.toFixed(3)}`; + else if (usd < 1000) formatted = `$${usd.toFixed(2)}`; + else if (usd < 1_000_000) formatted = `$${(usd / 1000).toFixed(2)}K`; + else formatted = `$${(usd / 1_000_000).toFixed(2)}M`; + + return ({formatted}); +} 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, + }); +} From 8ef818d4d3306b637c3cdae67aaf8a45c698bfcc Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 25 Mar 2026 08:42:35 +0000 Subject: [PATCH 2/4] =?UTF-8?q?[#517]=20Deduplicate=20USD=20formatting=20?= =?UTF-8?q?=E2=80=94=20use=20single=20formatUsdValue=20from=20lib/usd-pric?= =?UTF-8?q?e.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove duplicated formatUsd/formatPortfolioUsd/inline formatting from StoryCardStats, UsdPriceTag, and profile page. All now import formatUsdValue. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/profile/[address]/page.tsx | 12 +++--------- src/components/StoryCardStats.tsx | 14 ++++---------- src/components/UsdPriceTag.tsx | 10 ++-------- 3 files changed, 9 insertions(+), 27 deletions(-) diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index e632a7dc..15b5e5ba 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -16,16 +16,10 @@ 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"; -function formatPortfolioUsd(value: number): string { - 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`; -} export default function ProfilePage() { const params = useParams<{ address: string }>(); @@ -762,7 +756,7 @@ function PortfolioTab({ address }: { address: string }) { {plotUsd && ( - ≈ {formatPortfolioUsd(Number(formatUnits(totalValue, 18)) * plotUsd)} + ≈ {formatUsdValue(Number(formatUnits(totalValue, 18)) * plotUsd)} )} @@ -798,7 +792,7 @@ function PortfolioTab({ address }: { address: string }) { {plotUsd && ( - ({formatPortfolioUsd(Number(formatUnits(h.value, 18)) * plotUsd)}) + ({formatUsdValue(Number(formatUnits(h.value, 18)) * plotUsd)}) )}
diff --git a/src/components/StoryCardStats.tsx b/src/components/StoryCardStats.tsx index 2567b822..6d748fe9 100644 --- a/src/components/StoryCardStats.tsx +++ b/src/components/StoryCardStats.tsx @@ -7,6 +7,7 @@ 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); @@ -17,13 +18,6 @@ function formatCompact(value: string): string { return num.toFixed(2); } -function formatUsd(value: number): string { - 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`; -} /** Full stats row with price + TVL (used on detail pages) */ export function StoryCardStats({ tokenAddress }: { tokenAddress: string }) { @@ -50,10 +44,10 @@ export function StoryCardStats({ tokenAddress }: { tokenAddress: string }) { : "—"; const priceUsd = priceInfo && plotUsd - ? formatUsd(parseFloat(priceInfo.pricePerToken) * plotUsd) + ? formatUsdValue(parseFloat(priceInfo.pricePerToken) * plotUsd) : null; const tvlUsd = tvlData && plotUsd - ? formatUsd(parseFloat(tvlData.tvl) * plotUsd) + ? formatUsdValue(parseFloat(tvlData.tvl) * plotUsd) : null; return ( @@ -82,7 +76,7 @@ export function StoryCardTVL({ tokenAddress }: { tokenAddress: string }) { const tvlData = batchEntry?.tvl ?? individualTvl; const tvl = tvlData ? formatCompact(tvlData.tvl) : "—"; const tvlUsd = tvlData && plotUsd - ? formatUsd(parseFloat(tvlData.tvl) * plotUsd) + ? formatUsdValue(parseFloat(tvlData.tvl) * plotUsd) : null; return ( diff --git a/src/components/UsdPriceTag.tsx b/src/components/UsdPriceTag.tsx index ad1b381a..0ad706a8 100644 --- a/src/components/UsdPriceTag.tsx +++ b/src/components/UsdPriceTag.tsx @@ -1,6 +1,7 @@ "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. @@ -11,12 +12,5 @@ export function UsdPriceTag({ plotAmount }: { plotAmount: number }) { if (!plotUsd || plotAmount <= 0) return null; const usd = plotAmount * plotUsd; - let formatted: string; - if (usd < 0.01) formatted = "< $0.01"; - else if (usd < 1) formatted = `$${usd.toFixed(3)}`; - else if (usd < 1000) formatted = `$${usd.toFixed(2)}`; - else if (usd < 1_000_000) formatted = `$${(usd / 1000).toFixed(2)}K`; - else formatted = `$${(usd / 1_000_000).toFixed(2)}M`; - - return ({formatted}); + return ({formatUsdValue(usd)}); } From 521d24eaa162dc3f885bd5bc0bed7d4adb1937e7 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 25 Mar 2026 08:46:32 +0000 Subject: [PATCH 3/4] =?UTF-8?q?[#517]=20Add=20explicit=20testnet=20guard?= =?UTF-8?q?=20=E2=80=94=20skip=20USD=20price=20fetch=20for=20PL=5FTEST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testnet tokens have no real USD value; return null immediately on IS_TESTNET. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/usd-price.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/usd-price.ts b/lib/usd-price.ts index dafef90b..a06b0e16 100644 --- a/lib/usd-price.ts +++ b/lib/usd-price.ts @@ -9,7 +9,7 @@ * Reference: ~/Projects/dropcast/lib/usd-price.ts */ -import { PLOT_TOKEN } from "./contracts/constants"; +import { PLOT_TOKEN, IS_TESTNET } from "./contracts/constants"; // In-memory cache let cachedPrice: number | null = null; @@ -27,6 +27,9 @@ const PLOT_ADDRESS = PLOT_TOKEN.toLowerCase(); export async function getPlotUsdPrice( forceRefresh = false, ): Promise { + // Testnet tokens (PL_TEST) have no real USD value — skip upstream calls + if (IS_TESTNET) return null; + // Return cached price if fresh if (!forceRefresh && cachedPrice !== null && Date.now() - cacheTimestamp < CACHE_TTL) { return cachedPrice; From 2c02425c6d1b1db237e40d48902f8e52eb75d691 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 25 Mar 2026 08:48:18 +0000 Subject: [PATCH 4/4] =?UTF-8?q?[#517]=20Remove=20testnet=20guard=20per=20o?= =?UTF-8?q?perator=20=E2=80=94=20all=20features=20are=20mainnet-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/usd-price.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/usd-price.ts b/lib/usd-price.ts index a06b0e16..dafef90b 100644 --- a/lib/usd-price.ts +++ b/lib/usd-price.ts @@ -9,7 +9,7 @@ * Reference: ~/Projects/dropcast/lib/usd-price.ts */ -import { PLOT_TOKEN, IS_TESTNET } from "./contracts/constants"; +import { PLOT_TOKEN } from "./contracts/constants"; // In-memory cache let cachedPrice: number | null = null; @@ -27,9 +27,6 @@ const PLOT_ADDRESS = PLOT_TOKEN.toLowerCase(); export async function getPlotUsdPrice( forceRefresh = false, ): Promise { - // Testnet tokens (PL_TEST) have no real USD value — skip upstream calls - if (IS_TESTNET) return null; - // Return cached price if fresh if (!forceRefresh && cachedPrice !== null && Date.now() - cacheTimestamp < CACHE_TTL) { return cachedPrice;