diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 54e36a81..47542dae 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -1,6 +1,7 @@ import { createServerClient, type Storyline, type Plot } from "../../../../lib/supabase"; import { DeadlineCountdown } from "../../../components/DeadlineCountdown"; import { TradingWidget } from "../../../components/TradingWidget"; +import { PriceChart } from "../../../components/PriceChart"; import { getTokenPrice, type TokenPriceInfo } from "../../../../lib/price"; import { IS_TESTNET } from "../../../../lib/contracts/constants"; import { type Address } from "viem"; @@ -53,6 +54,13 @@ export default async function StoryPage({ params }: { params: Params }) { return (
+ {sl.token_address && priceInfo && ( + + )} {sl.token_address && ( )} diff --git a/src/components/PriceChart.tsx b/src/components/PriceChart.tsx new file mode 100644 index 00000000..cf714444 --- /dev/null +++ b/src/components/PriceChart.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { type Address, parseUnits, formatUnits } from "viem"; +import { publicClient } from "../../lib/rpc"; +import { mcv2BondAbi } from "../../lib/price"; +import { MCV2_BOND, IS_TESTNET } from "../../lib/contracts/constants"; + +const CHART_W = 320; +const CHART_H = 140; +const PAD = { top: 10, right: 10, bottom: 24, left: 48 }; +const PLOT_W = CHART_W - PAD.left - PAD.right; +const PLOT_H = CHART_H - PAD.top - PAD.bottom; +const NUM_POINTS = 20; + +interface PriceChartProps { + tokenAddress: Address; + totalSupplyRaw: bigint; + currentPriceRaw: bigint; +} + +/** + * Lightweight bonding curve chart. + * + * Samples getReserveForToken at evenly spaced supply points to plot + * the price curve, then marks the current supply position. + */ +export function PriceChart({ tokenAddress, totalSupplyRaw, currentPriceRaw }: PriceChartProps) { + const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + + // Sample the bonding curve at multiple supply points + const { data: curvePoints } = useQuery({ + queryKey: ["price-curve", tokenAddress], + queryFn: async () => { + // Sample from 1 token to 2x current supply (or a minimum of 100 tokens) + const minMax = parseUnits("100", 18); + const maxSupply = + totalSupplyRaw * BigInt(2) > minMax + ? totalSupplyRaw * BigInt(2) + : minMax; + + const points: { supply: number; price: number }[] = []; + const step = maxSupply / BigInt(NUM_POINTS); + if (step === BigInt(0)) return []; + + // Query cumulative costs for increasing mint amounts from current supply + const promises: Promise[] = []; + for (let i = 1; i <= NUM_POINTS; i++) { + const amount = step * BigInt(i); + promises.push( + publicClient + .readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getReserveForToken", + args: [tokenAddress, amount], + }) + .catch(() => BigInt(0)), + ); + } + + const cumulativeCosts = await Promise.all(promises); + + // Start with the actual current supply/price point + const currentSupplyNum = Number(formatUnits(totalSupplyRaw, 18)); + const currentPriceNum = Number(formatUnits(currentPriceRaw, 18)); + points.push({ supply: currentSupplyNum, price: currentPriceNum }); + + // Compute marginal price at each future supply step + let prevCost = BigInt(0); + for (let i = 0; i < cumulativeCosts.length; i++) { + const amount = step * BigInt(i + 1); + const marginalCost = cumulativeCosts[i] - prevCost; + const pricePerToken = + Number(formatUnits(marginalCost, 18)) / + Number(formatUnits(step, 18)); + points.push({ + supply: currentSupplyNum + Number(formatUnits(amount, 18)), + price: pricePerToken, + }); + prevCost = cumulativeCosts[i]; + } + return points; + }, + staleTime: 60000, + }); + + if (!curvePoints || curvePoints.length === 0) return null; + + // Scale to chart coords + const maxX = Math.max(...curvePoints.map((p) => p.supply)); + const maxY = Math.max(...curvePoints.map((p) => p.price)); + if (maxX === 0 || maxY === 0) return null; + + const scaleX = (v: number) => PAD.left + (v / maxX) * PLOT_W; + const scaleY = (v: number) => PAD.top + PLOT_H - (v / maxY) * PLOT_H; + + // Build SVG polyline + const linePoints = curvePoints + .map((p) => `${scaleX(p.supply)},${scaleY(p.price)}`) + .join(" "); + + // Current supply marker — uses actual current supply and price + const currentSupply = Number(formatUnits(totalSupplyRaw, 18)); + const currentPrice = Number(formatUnits(currentPriceRaw, 18)); + const markerX = scaleX(currentSupply); + const markerY = scaleY(currentPrice); + + // Y-axis labels (3 ticks) + const yTicks = [0, maxY / 2, maxY]; + // X-axis labels + const xTicks = [0, maxX / 2, maxX]; + + return ( +
+

Price Curve

+ + {/* Grid lines */} + {yTicks.map((v, i) => ( + + ))} + + {/* Y-axis labels */} + {yTicks.map((v, i) => ( + + {v < 0.001 ? v.toExponential(0) : v.toFixed(4)} + + ))} + + {/* X-axis labels */} + {xTicks.map((v, i) => ( + + {v < 1 ? v.toFixed(1) : Math.round(v).toLocaleString()} + + ))} + + {/* Curve */} + + + {/* Current supply marker */} + {currentSupply > 0 && ( + <> + + + + )} + +

+ Supply vs. price per token ({reserveLabel}) + {currentSupply > 0 && ( + + {" "} + · current: {currentSupply.toLocaleString()} tokens + + )} +

+
+ ); +}