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
119 changes: 119 additions & 0 deletions lib/usd-price.ts
Original file line number Diff line number Diff line change
@@ -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<number | null> | null = null;

const PLOT_ADDRESS = PLOT_TOKEN.toLowerCase();

/**
* Get PLOT token USD price with fallback chain
*/
export async function getPlotUsdPrice(
forceRefresh = false,
): Promise<number | null> {
// 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<number | null> {
// 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`;
}
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.4",
"version": "0.1.5",
"private": true,
"workspaces": [
"packages/*"
Expand Down
16 changes: 16 additions & 0 deletions src/app/api/tokens/plot-price/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
},
);
}
23 changes: 20 additions & 3 deletions src/app/profile/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@
import { useQuery } from "@tanstack/react-query";
import { formatUnits, type Address } from "viem";
import Link from "next/link";
import { supabase, type Storyline, type Donation, type TradeHistory } from "../../../../lib/supabase";

Check warning on line 9 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'TradeHistory' is defined but never used
import { STORY_FACTORY, RESERVE_LABEL, EXPLORER_URL, MCV2_BOND, PLOT_TOKEN } from "../../../../lib/contracts/constants";
import { getFarcasterProfile, fetchAgentMetadata } from "../../../../lib/actions";
import { truncateAddress } from "../../../../lib/utils";
import { formatPrice } from "../../../../lib/format";
import { getTokenPrice, mcv2BondAbi, erc20Abi, type TokenPriceInfo } from "../../../../lib/price";

Check warning on line 14 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'TokenPriceInfo' is defined but never used
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();
Expand Down Expand Up @@ -576,6 +579,8 @@
}

function PortfolioTab({ address }: { address: string }) {
const { data: plotUsd } = usePlotUsdPrice();

// Fetch on-chain token holdings
const { data: holdings, isLoading: holdingsLoading } = useQuery({
queryKey: ["profile-holdings", address],
Expand Down Expand Up @@ -749,6 +754,11 @@
<span className="text-accent text-lg font-bold">
{formatPrice(formatUnits(totalValue, 18))} {RESERVE_LABEL}
</span>
{plotUsd && (
<span className="text-muted ml-2 text-sm">
≈ {formatUsdValue(Number(formatUnits(totalValue, 18)) * plotUsd)}
</span>
)}
<span className="text-muted ml-2 text-xs">
across {holdings!.length} {holdings!.length === 1 ? "token" : "tokens"}
</span>
Expand Down Expand Up @@ -776,9 +786,16 @@
</span>
)}
</div>
<span className="text-accent shrink-0 text-sm font-medium">
{formatPrice(formatUnits(h.value, 18))} {RESERVE_LABEL}
</span>
<div className="shrink-0 text-right">
<span className="text-accent text-sm font-medium">
{formatPrice(formatUnits(h.value, 18))} {RESERVE_LABEL}
</span>
{plotUsd && (
<span className="text-muted ml-1 text-xs">
({formatUsdValue(Number(formatUnits(h.value, 18)) * plotUsd)})
</span>
)}
</div>
</div>
<div className="text-muted mt-1.5 flex flex-wrap gap-x-4 gap-y-0.5 text-xs">
<span>
Expand Down
2 changes: 2 additions & 0 deletions src/app/story/[storylineId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;

Expand Down Expand Up @@ -263,6 +264,7 @@ function StoryHeader({
</span>
<span className="font-semibold text-accent">
{formatPrice(priceInfo.pricePerToken)} {reserveLabel}
<UsdPriceTag plotAmount={parseFloat(priceInfo.pricePerToken)} />
</span>
</div>
<div>
Expand Down
21 changes: 18 additions & 3 deletions src/components/StoryCardStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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],
Expand All @@ -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 (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-[var(--text-muted)]">
<span>Price: <span className="font-semibold text-[var(--accent)]">{price} {RESERVE_LABEL}</span></span>
<span>TVL: <span className="font-semibold text-[var(--accent)]">{tvl} {RESERVE_LABEL}</span></span>
<span>Price: <span className="font-semibold text-[var(--accent)]">{price} {RESERVE_LABEL}</span>{priceUsd && <span className="ml-1 opacity-60">({priceUsd})</span>}</span>
<span>TVL: <span className="font-semibold text-[var(--accent)]">{tvl} {RESERVE_LABEL}</span>{tvlUsd && <span className="ml-1 opacity-60">({tvlUsd})</span>}</span>
</div>
);
}
Expand All @@ -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({
Expand All @@ -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 (
<span>TVL: <span className="font-semibold text-[var(--accent)]">{tvl} {RESERVE_LABEL}</span></span>
<span>TVL: <span className="font-semibold text-[var(--accent)]">{tvl} {RESERVE_LABEL}</span>{tvlUsd && <span className="ml-1 opacity-60">({tvlUsd})</span>}</span>
);
}
16 changes: 16 additions & 0 deletions src/components/UsdPriceTag.tsx
Original file line number Diff line number Diff line change
@@ -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 <span className="ml-1 opacity-60">({formatUsdValue(usd)})</span>;
}
22 changes: 22 additions & 0 deletions src/hooks/usePlotUsdPrice.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
Loading