From e0d5e43c763c9f197c84d6435001049c5a565264 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 19:56:34 +0000 Subject: [PATCH 1/3] [#25] Add bonding curve price chart to story page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #25 - P5-3a: New PriceChart component — samples getReserveForToken at evenly spaced supply points to render an SVG bonding curve - Marks current supply position with a dashed line and dot - Terminal aesthetic: monospace labels, accent-colored curve - Placed between price info card and trading widget on story page Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/story/[storylineId]/page.tsx | 7 + src/components/PriceChart.tsx | 200 +++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 src/components/PriceChart.tsx diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 54e36a81..808de414 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,12 @@ 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..34b8c48a --- /dev/null +++ b/src/components/PriceChart.tsx @@ -0,0 +1,200 @@ +"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; +} + +/** + * 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 }: 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 oneToken = BigInt(10 ** 18); + 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 []; + + const promises: Promise<{ supply: bigint; price: bigint }>[] = []; + for (let i = 1; i <= NUM_POINTS; i++) { + const supplyAt = step * BigInt(i); + promises.push( + publicClient + .readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getReserveForToken", + args: [tokenAddress, oneToken], + }) + .then((price) => ({ supply: supplyAt, price })) + .catch(() => ({ supply: supplyAt, price: BigInt(0) })), + ); + } + + const results = await Promise.all(promises); + for (const r of results) { + points.push({ + supply: Number(formatUnits(r.supply, 18)), + price: Number(formatUnits(r.price, 18)), + }); + } + 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 + const currentSupply = Number(formatUnits(totalSupplyRaw, 18)); + const currentPrice = curvePoints.length > 0 ? curvePoints[curvePoints.length - 1]?.price ?? 0 : 0; + // Find the closest point to current supply for y position + let markerPrice = currentPrice; + for (const p of curvePoints) { + if (p.supply >= currentSupply) { + markerPrice = p.price; + break; + } + } + const markerX = scaleX(Math.min(currentSupply, maxX)); + const markerY = scaleY(markerPrice); + + // 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 + + )} +

+
+ ); +} From 24e0781042ac0307ce23cf99f4c1010bf61e8d33 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 19:58:48 +0000 Subject: [PATCH 2/3] [#25] Fix curve sampling to compute marginal prices at each supply step Queries cumulative getReserveForToken costs for increasing mint amounts and computes marginal price per token at each step, producing a real bonding curve shape instead of a flat line. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/PriceChart.tsx | 46 ++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/components/PriceChart.tsx b/src/components/PriceChart.tsx index 34b8c48a..e8558b94 100644 --- a/src/components/PriceChart.tsx +++ b/src/components/PriceChart.tsx @@ -32,7 +32,6 @@ export function PriceChart({ tokenAddress, totalSupplyRaw }: PriceChartProps) { queryKey: ["price-curve", tokenAddress], queryFn: async () => { // Sample from 1 token to 2x current supply (or a minimum of 100 tokens) - const oneToken = BigInt(10 ** 18); const minMax = parseUnits("100", 18); const maxSupply = totalSupplyRaw * BigInt(2) > minMax @@ -43,28 +42,39 @@ export function PriceChart({ tokenAddress, totalSupplyRaw }: PriceChartProps) { const step = maxSupply / BigInt(NUM_POINTS); if (step === BigInt(0)) return []; - const promises: Promise<{ supply: bigint; price: bigint }>[] = []; + // Query cumulative costs for increasing mint amounts from current supply + const promises: Promise[] = []; for (let i = 1; i <= NUM_POINTS; i++) { - const supplyAt = step * BigInt(i); + const amount = step * BigInt(i); promises.push( publicClient .readContract({ address: MCV2_BOND, abi: mcv2BondAbi, functionName: "getReserveForToken", - args: [tokenAddress, oneToken], + args: [tokenAddress, amount], }) - .then((price) => ({ supply: supplyAt, price })) - .catch(() => ({ supply: supplyAt, price: BigInt(0) })), + .catch(() => BigInt(0)), ); } - const results = await Promise.all(promises); - for (const r of results) { + const cumulativeCosts = await Promise.all(promises); + + // Compute marginal price at each supply step + let prevCost = BigInt(0); + const currentSupplyNum = Number(formatUnits(totalSupplyRaw, 18)); + for (let i = 0; i < cumulativeCosts.length; i++) { + const amount = step * BigInt(i + 1); + const marginalCost = cumulativeCosts[i] - prevCost; + // Price per token = marginal cost / step size + const pricePerToken = + Number(formatUnits(marginalCost, 18)) / + Number(formatUnits(step, 18)); points.push({ - supply: Number(formatUnits(r.supply, 18)), - price: Number(formatUnits(r.price, 18)), + supply: currentSupplyNum + Number(formatUnits(amount, 18)), + price: pricePerToken, }); + prevCost = cumulativeCosts[i]; } return points; }, @@ -86,19 +96,11 @@ export function PriceChart({ tokenAddress, totalSupplyRaw }: PriceChartProps) { .map((p) => `${scaleX(p.supply)},${scaleY(p.price)}`) .join(" "); - // Current supply marker + // Current supply marker — first point on the curve is closest to current supply const currentSupply = Number(formatUnits(totalSupplyRaw, 18)); - const currentPrice = curvePoints.length > 0 ? curvePoints[curvePoints.length - 1]?.price ?? 0 : 0; - // Find the closest point to current supply for y position - let markerPrice = currentPrice; - for (const p of curvePoints) { - if (p.supply >= currentSupply) { - markerPrice = p.price; - break; - } - } - const markerX = scaleX(Math.min(currentSupply, maxX)); - const markerY = scaleY(markerPrice); + const firstPoint = curvePoints[0]; + const markerX = scaleX(firstPoint.supply); + const markerY = scaleY(firstPoint.price); // Y-axis labels (3 ticks) const yTicks = [0, maxY / 2, maxY]; From 48a4704875ce187680d413dccc5f19a43abdd097 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 20:00:12 +0000 Subject: [PATCH 3/3] [#25] Fix current supply marker to use actual price data Adds currentPriceRaw prop from server-side getTokenPrice result. Inserts actual current supply/price as the first curve point and uses it for the marker position instead of the first future sample. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/story/[storylineId]/page.tsx | 1 + src/components/PriceChart.tsx | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 808de414..47542dae 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -58,6 +58,7 @@ export default async function StoryPage({ params }: { params: Params }) { )} {sl.token_address && ( diff --git a/src/components/PriceChart.tsx b/src/components/PriceChart.tsx index e8558b94..cf714444 100644 --- a/src/components/PriceChart.tsx +++ b/src/components/PriceChart.tsx @@ -16,6 +16,7 @@ const NUM_POINTS = 20; interface PriceChartProps { tokenAddress: Address; totalSupplyRaw: bigint; + currentPriceRaw: bigint; } /** @@ -24,7 +25,7 @@ interface PriceChartProps { * Samples getReserveForToken at evenly spaced supply points to plot * the price curve, then marks the current supply position. */ -export function PriceChart({ tokenAddress, totalSupplyRaw }: PriceChartProps) { +export function PriceChart({ tokenAddress, totalSupplyRaw, currentPriceRaw }: PriceChartProps) { const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; // Sample the bonding curve at multiple supply points @@ -60,13 +61,16 @@ export function PriceChart({ tokenAddress, totalSupplyRaw }: PriceChartProps) { const cumulativeCosts = await Promise.all(promises); - // Compute marginal price at each supply step - let prevCost = BigInt(0); + // 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; - // Price per token = marginal cost / step size const pricePerToken = Number(formatUnits(marginalCost, 18)) / Number(formatUnits(step, 18)); @@ -96,11 +100,11 @@ export function PriceChart({ tokenAddress, totalSupplyRaw }: PriceChartProps) { .map((p) => `${scaleX(p.supply)},${scaleY(p.price)}`) .join(" "); - // Current supply marker — first point on the curve is closest to current supply + // Current supply marker — uses actual current supply and price const currentSupply = Number(formatUnits(totalSupplyRaw, 18)); - const firstPoint = curvePoints[0]; - const markerX = scaleX(firstPoint.supply); - const markerY = scaleY(firstPoint.price); + 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];