diff --git a/app/README.md b/app/README.md index d723566..1f1b2e0 100644 --- a/app/README.md +++ b/app/README.md @@ -25,6 +25,56 @@ Next 16 app router, React 19, Tailwind 4, wagmi 3 + viem 2, Postgres (postgres.j - `db/schema.sql` — `bb_launches`, `bb_launch_swaps`, `bb_launch_fee_events`, `bb_launch_meta`, `bb_launch_sync_state` (+ `bb_migrations`). Idempotent; applied on every deploy. +## Token charts + +Token pages prefer GeckoTerminal’s hosted advanced chart. The lookup validates the +exact chain, Uniswap v4 pool ID, launched base token and quote. Reversed listings +are not used, because the embed would chart the quote asset instead of the token. +Base, Robinhood and Arc use their own provider network IDs. Arc pools retain the +factory's USDC ERC-20 quote identity; native USDC is not substituted for it. + +Unlisted, unpriced or temporarily unavailable pools fall back to Openlaunch’s own +indexed candles. **On-chain** is also available manually if a provider frame is +blank, blocked or slow; an iframe load event cannot prove its chart rendered. +Tokens without indexed swaps show **No trades yet**, not fabricated launch-price +candles. The existing shared live clock refreshes native history and can discover +the first swap. Retry Advanced to recheck a previously unavailable provider. + +The native renderer is lazy-loaded and retains extreme-value normalization, +quote/USD and price/market-cap modes, volume, read-only wallet markers and bounded +history. The swap panel, stats, indexer and public candle API are unchanged. + +`GET /api/launch/chart-provider?chain=&token=&pool="e=` returns a classified +status. It deduplicates lookups, caches ready/reversed metadata for 15 minutes and +unlisted/unpriced results for 60 seconds (at most 1,000 entries per process). +Requests time out after 8 seconds. Limits are 60 requests/IP/minute, 10 uncached +upstream calls/minute and two concurrent upstream requests per process; HTTP 429 +starts a 60-second upstream cooldown. Cache hits remain available during cooldown. +Scale-out does not supply a shared global limit; use a shared cache/budget if needed. +Provider errors are never cached as missing listings. + +Gecko’s official embed options set a black background in dark mode, the site’s +light paper in light mode, and its grayscale logo. Gecko’s own toolbar handles +intervals, drawing tools, indicators and display modes; availability remains under +the provider’s control. Attribution stays visible. Changing site theme, reloading, +or switching sources may reset drawings. No proprietary chart library files are +redistributed. + +Only the fixed Gecko API origin is requested by the server, without viewer wallet +addresses or credentials. The browser contacts Gecko directly for the iframe. +CSP allows only `https://www.geckoterminal.com` as a frame origin on all entry +pages, preserving client navigation. It does not allow provider scripts or +connections in the parent page; `frame-ancestors` remains `none`. + +The development-only `/ui-review-charts` exercises eight real pool identities, +including an Arc USDC-quoted pool, without a local database. Its allowlisted candle +proxy reads the public production +API without forwarding wallets, cookies or credentials. Both preview endpoints +return 404 in production. See its [test notes](src/app/ui-review-charts/README.md). + +No schema migration is required. Reverting the chart integration restores the +previous native-only UI; stored candle history remains untouched. + ## Local dev against a Base fork ``` anvil --fork-url https://mainnet.base.org --port 8545 --chain-id 8453 diff --git a/app/src/app/api/launch/chart-provider/route.test.ts b/app/src/app/api/launch/chart-provider/route.test.ts new file mode 100644 index 0000000..858672a --- /dev/null +++ b/app/src/app/api/launch/chart-provider/route.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { GET } from "./route"; +const identity={chain:"base",token:"0x"+"a".repeat(40),quote:"0x"+"0".repeat(40),pool:"0x"+"b".repeat(64)}; +function request(overrides: Partial = {}, ip="chart-test") { + return new Request("http://localhost/api/launch/chart-provider?"+new URLSearchParams({...identity,...overrides}),{headers:{"fly-client-ip":ip}}); +} +test("route validates without network, serves classified data, and rate limits clients",async(t)=>{ + let now=Date.now();t.mock.method(Date,"now",()=>now); + const response={data:{type:"pool",id:"base_"+identity.pool,attributes:{address:identity.pool,base_token_price_usd:"1"}, + relationships:{base_token:{data:{type:"token",id:"base_"+identity.token}},quote_token:{data:{type:"token",id:"base_"+identity.quote}}}}}; + const upstream=t.mock.method(globalThis,"fetch",async()=>Response.json(response)); + for(const bad of [{chain:"ethereum"},{pool:"https://evil.test"},{token:identity.quote}]) assert.equal((await GET(request(bad))).status,400); + assert.equal(upstream.mock.callCount(),0); + const result=await GET(request());assert.equal(result.status,200);assert.deepEqual(await result.json(),{status:"ready"}); + assert.equal(result.headers.get("cache-control"),"no-store"); + await GET(request());assert.equal(upstream.mock.callCount(),1); + const mismatch=await GET(request({quote:"0x"+"c".repeat(40)}));assert.equal(mismatch.status,503); + assert.equal(mismatch.headers.get("retry-after"),"60"); + now+=60_001; + for(let i=0;i<60;i++) assert.equal((await GET(request({},"limited"))).status,200); + assert.equal((await GET(request({},"limited"))).status,429); + now+=60_001;assert.equal((await GET(request({},"limited"))).status,200); +}); + +test("Arc provider route preserves exact identity and recoverable listing, pricing and upstream failures", async (t) => { + let now = Date.now() + 300_000; + t.mock.method(Date, "now", () => now); + const arc = { ...identity, chain: "arc", quote: "0x3600000000000000000000000000000000000000" }; + const payload = (pool: string, price: unknown = "0.00001") => ({ data: { + type: "pool", id: `arc_${pool}`, attributes: { address: pool, base_token_price_usd: price }, + relationships: { + base_token: { data: { type: "token", id: `arc_${arc.token}` } }, + quote_token: { data: { type: "token", id: `arc_${arc.quote}` } }, + }, + } }); + const scenarios: { name: string; response: (pool: string) => Response; status: number; lookup?: string }[] = [ + { name: "listed", response: (pool) => Response.json(payload(pool)), status: 200, lookup: "ready" }, + { name: "missing", response: () => new Response(null, { status: 404 }), status: 200, lookup: "unlisted" }, + { name: "unpriced", response: (pool) => Response.json(payload(pool, null)), status: 200, lookup: "unpriced" }, + { name: "reversed", response: (pool) => { + const data = payload(pool); + [data.data.relationships.base_token, data.data.relationships.quote_token] = [data.data.relationships.quote_token, data.data.relationships.base_token]; + return Response.json(data); + }, status: 200, lookup: "inverted" }, + { name: "upstream failure", response: () => new Response(null, { status: 500 }), status: 503 }, + { name: "malformed", response: () => Response.json({ data: [] }), status: 503 }, + { name: "timeout", response: () => { throw new DOMException("Timed out", "TimeoutError"); }, status: 503 }, + { name: "wrong quote", response: (pool) => { + const data = payload(pool); + data.data.relationships.quote_token.data.id = `arc_${identity.quote}`; + return Response.json(data); + }, status: 503 }, + ]; + let upstreamResponse: (pool: string) => Response; + const upstream = t.mock.method(globalThis, "fetch", async (url: unknown) => { + const target = new URL(String(url)); + assert.equal(target.origin, "https://api.geckoterminal.com"); + assert.ok(target.pathname.startsWith("/api/v2/networks/arc/pools/")); + return upstreamResponse(target.pathname.split("/").at(-1)!); + }); + for (const [index, scenario] of scenarios.entries()) { + now += 60_001; + upstreamResponse = scenario.response; + const current = { ...arc, pool: "0x" + (index + 100).toString(16).padStart(64, "0") }; + const result = await GET(request(current, "arc-chart-test")); + assert.equal(result.status, scenario.status, scenario.name); + assert.equal(result.headers.get("cache-control"), "no-store"); + if (scenario.lookup) assert.deepEqual(await result.json(), { status: scenario.lookup }); + else { + assert.match((await result.json()).error, /On-chain history is still available/); + assert.equal(result.headers.get("retry-after"), "60"); + const calls = upstream.mock.callCount(); + upstreamResponse = (pool) => Response.json(payload(pool)); + assert.deepEqual(await (await GET(request(current, "arc-chart-test"))).json(), { status: "ready" }); + assert.equal(upstream.mock.callCount(), calls + 1, "errors must not be cached as absent pools"); + } + } +}); diff --git a/app/src/app/api/launch/chart-provider/route.ts b/app/src/app/api/launch/chart-provider/route.ts new file mode 100644 index 0000000..9265b69 --- /dev/null +++ b/app/src/app/api/launch/chart-provider/route.ts @@ -0,0 +1,21 @@ +import { isChartPool } from "@/lib/launchpad/chart-pool"; +import { lookupGeckoPool } from "@/lib/launchpad/geckoterminal"; +import { createGeckoCache } from "@/lib/launchpad/gecko-cache"; +import { rateLimited } from "@/lib/launchpad/editServer"; + +export const dynamic = "force-dynamic"; +const lookup = createGeckoCache(lookupGeckoPool, () => Date.now()); + +export async function GET(request: Request) { + const headers = { "cache-control": "no-store" }; + const ip = (request.headers.get("fly-client-ip") || request.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + if (rateLimited(`chart:ip:${ip}`, 60)) return Response.json({ error: "Too many chart requests. Try again shortly." }, { status: 429, headers: { ...headers, "retry-after": "60" } }); + const query = new URL(request.url).searchParams; + const pool = { chain: query.get("chain"), token: query.get("token"), poolId: query.get("pool"), quote: query.get("quote") }; + if (!isChartPool(pool)) return Response.json({ error: "Invalid pool." }, { status: 400, headers }); + try { + return Response.json({ status: await lookup(pool) }, { headers }); + } catch { + return Response.json({ error: "GeckoTerminal could not be checked. On-chain history is still available." }, { status: 503, headers: { ...headers, "retry-after": "60" } }); + } +} diff --git a/app/src/app/t/[chain]/[token]/page.tsx b/app/src/app/t/[chain]/[token]/page.tsx index bc60441..93b22b1 100644 --- a/app/src/app/t/[chain]/[token]/page.tsx +++ b/app/src/app/t/[chain]/[token]/page.tsx @@ -9,7 +9,7 @@ import TradePanel from "@/components/launchpad/TradePanel"; import CollectPanel from "@/components/launchpad/CollectPanel"; import CopyChip from "@/components/launchpad/CopyChip"; import MobileBuyBar from "@/components/launchpad/MobileBuyBar"; -import PriceChart from "@/components/launchpad/PriceChart"; +import TokenChart from "@/components/launchpad/TokenChart"; import TokenComments from "@/components/launchpad/Posts"; import ChangeChip from "@/components/launchpad/ChangeChip"; import HoldersPanel from "@/components/launchpad/HoldersPanel"; @@ -135,7 +135,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s
- + 0} />
diff --git a/app/src/app/ui-review-charts/README.md b/app/src/app/ui-review-charts/README.md new file mode 100644 index 0000000..3d644c0 --- /dev/null +++ b/app/src/app/ui-review-charts/README.md @@ -0,0 +1,56 @@ +# GeckoTerminal chart preview + +Run `npm run dev -- --hostname 127.0.0.1 --port 3005` from `app`, then open +[the preview](http://127.0.0.1:3005/ui-review-charts?pool=solv). + +This is the production TokenChart component, not a separate mock. The surrounding +page is deliberately read-only and has no swap panel. Real token pages retain +their existing trade panel, token data and conversations. + +## Cases + +| Preset | Purpose at the September 13, 2026 audit | +| --- | --- | +| solv | Actively traded Base pool, primary advanced chart | +| rfly | Actively traded Robinhood pool | +| ocat | Arc USDC-quoted pool; exact Gecko identity and USD pricing verified September 16, 2026 | +| sky | Sparse ETH-quoted history | +| quiver | Stock quote, sparse history | +| unpriced | GITLAWB quote with trades but no Gecko USD price | +| inverted | Gecko lists GITLAWB first; use the launched token’s native history | +| new | No indexed trades; empty state with no synthetic candles | + +Statuses and prices can change. Preview candles come from Openlaunch’s public +API through an allowlisted development-only proxy. No database credentials, +wallet addresses or cookies are sent. Both this page and its candle proxy return +404 outside development. Regular token pages use their local indexed candle API. + +## Manual verification + +- Test dark and light themes. Gecko uses supported `bg_color`, `light_chart` + and `grayscale=1` options; its attribution must remain visible. +- Confirm real candles, volume, interval selection, Indicators and drawing tools. +- Use **On-chain** and return to **Advanced**. A provider lookup or frame failure + must not disable trading or claim the token has no trades. +- Inspect unpriced and reversed examples: quote symbols and token orientation must + stay correct. An unavailable USD conversion must not become a fake dollar price. +- Check the Arc preset: its quote is the 6-decimal USDC ERC-20 at `0x3600…0000`, + not the native zero-address asset. **On-chain** must keep USDC as its quote unit. +- Verify **No trades yet**, sparse ranges and native chart refresh. +- Check 400px mobile, 720px workspace and 1440px desktop widths, with no page-level + horizontal overflow. Provider controls are responsive and may collapse. +- Navigate into a token page from another route to exercise the initial document’s CSP. + +## Limits and ownership + +Gecko owns hosted chart data, UI, storage and uptime. Pool metadata does not prove +every chart has candles; **On-chain** remains the escape hatch. Reloads, theme and +source changes can reset drawings. No CSS filters, overlays, clipping or hidden +branding are applied. + +The September 13 audit covered Base and Robinhood, before Arc was added. The +Arc preset was verified separately; it is not a full Arc-pool coverage audit. +The saved audit checked 1,012 pool identities, not every candle or every rendered +iframe. 988 had exact orientation; 314 of those had a positive USD price, while +674 did not. Seven listings were reversed and 17 were absent. These are +point-in-time observations, not a production coverage guarantee. diff --git a/app/src/app/ui-review-charts/candles/route.test.ts b/app/src/app/ui-review-charts/candles/route.test.ts new file mode 100644 index 0000000..4060677 --- /dev/null +++ b/app/src/app/ui-review-charts/candles/route.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { GET } from "./route"; +import { REVIEW_POOLS } from "../pools"; +import { CHAIN_KEYS } from "@/lib/chainKeys"; + +test("preview is production-disabled and forwards only allowlisted public candle parameters", async (t) => { + const original = process.env.NODE_ENV; + try { + const pool = REVIEW_POOLS[0]; + const query = new URLSearchParams({ chain: pool.chain, token: pool.token, interval: "15m", wallet: "must-not-leak" }); + const request = () => new Request("http://localhost/ui-review-charts/candles?" + query, { headers: { cookie: "private=must-not-leak" } }); + const upstream = t.mock.method(globalThis, "fetch", async (url: unknown, init?: RequestInit) => { + const target = new URL(String(url)); + assert.equal(target.origin, "https://openlaunch.lol"); + assert.equal(target.pathname, "/api/launch/candles"); + assert.equal(target.searchParams.get("chain"), query.get("chain")); + assert.equal(target.searchParams.get("token"), query.get("token")); + assert.equal(target.searchParams.has("wallet"), false); + assert.deepEqual(init?.headers, { accept: "application/json" }); + assert.equal(init?.redirect, "error"); + return Response.json({ candles: [] }); + }); + Object.assign(process.env, { NODE_ENV: "production" }); + assert.equal((await GET(request())).status, 404); + assert.equal(upstream.mock.callCount(), 0); + Object.assign(process.env, { NODE_ENV: "development" }); + query.set("token", "0x" + "f".repeat(40)); + assert.equal((await GET(request())).status, 400); + assert.equal(upstream.mock.callCount(), 0); + for (const chain of CHAIN_KEYS) { + const example = REVIEW_POOLS.find((candidate) => candidate.chain === chain); + assert.ok(example, `missing a public ${chain} preview`); + query.set("chain", chain); + query.set("token", example.token); + assert.equal((await GET(request())).status, 200); + } + assert.equal(upstream.mock.callCount(), CHAIN_KEYS.length); + } finally { + if (original === undefined) delete process.env.NODE_ENV; + else Object.assign(process.env, { NODE_ENV: original }); + } +}); diff --git a/app/src/app/ui-review-charts/candles/route.ts b/app/src/app/ui-review-charts/candles/route.ts new file mode 100644 index 0000000..6e91962 --- /dev/null +++ b/app/src/app/ui-review-charts/candles/route.ts @@ -0,0 +1,33 @@ +import { REVIEW_POOLS } from "../pools"; +import { isInterval } from "@/lib/launchpad/candles"; +import { memo } from "@/lib/launchpad/memo"; +import { rateLimited } from "@/lib/launchpad/editServer"; + +export const dynamic = "force-dynamic"; + +/** Read-only, allowlisted preview data. Never exposed in production. */ +export async function GET(request: Request) { + if (process.env.NODE_ENV !== "development") return new Response(null, { status: 404 }); + const headers = { "cache-control": "no-store" }; + const input = new URL(request.url).searchParams; + const pool = REVIEW_POOLS.find((item) => item.chain === input.get("chain") && item.token === input.get("token")?.toLowerCase()); + const interval = input.get("interval"); + const from = input.get("from"); + if (!pool || !isInterval(interval) || (from !== null && (!/^\d{1,11}$/.test(from) || Number(from) > Math.floor(Date.now() / 1000)))) return Response.json({ error: "Invalid preview request." }, { status: 400, headers }); + if (rateLimited("chart:review", 60)) return Response.json({ error: "Try again shortly." }, { status: 429, headers }); + // Construct a fresh allowlist; never forward wallet, cookies or credentials. + const query = new URLSearchParams({ chain: pool.chain, token: pool.token, interval }); + if (from) query.set("from", from); + try { + const data = await memo("chart:review:" + query, 15_000, async () => { + const response = await fetch("https://openlaunch.lol/api/launch/candles?" + query, { + headers: { accept: "application/json" }, cache: "no-store", redirect: "error", signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) throw new Error("Public preview unavailable."); + return response.json(); + }); + return Response.json(data, { headers }); + } catch { + return Response.json({ error: "Public preview data is temporarily unavailable." }, { status: 502, headers }); + } +} diff --git a/app/src/app/ui-review-charts/page.tsx b/app/src/app/ui-review-charts/page.tsx new file mode 100644 index 0000000..a0aa0cc --- /dev/null +++ b/app/src/app/ui-review-charts/page.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowUpRight } from "lucide-react"; +import TokenChart from "@/components/launchpad/TokenChart"; +import { CHAIN_SHORT } from "@/lib/chainPublic"; +import { REVIEW_POOLS } from "./pools"; + +export const dynamic = "force-dynamic"; +export const metadata: Metadata = { title: "Chart preview", robots: { index: false, follow: false } }; + +export default async function ChartReview({ searchParams }: { searchParams: Promise<{ pool?: string }> }) { + if (process.env.NODE_ENV !== "development") notFound(); + const { pool: id } = await searchParams; + const pool = REVIEW_POOLS.find((candidate) => candidate.id === id) ?? REVIEW_POOLS[0]; + + return
+
+
+

Chart preview

+

The token-page chart with real Openlaunch pools. Check Base, Robinhood and Arc, plus unindexed-pool states. No trades or wallet actions.

+
+ View live token +
+ + +
+ Pool identity and trial notes +
+
Exact Uniswap v4 pool
{pool.poolId}
+
Token contract
{pool.token}
+
+

GeckoTerminal is primary for exact, correctly oriented pools with a usable USD price. Otherwise the same component shows Openlaunch’s indexed candles. This development-only preview reads public candle data from openlaunch.lol; production uses its own database. Provider branding stays visible, using Gecko’s grayscale logo option.

+
+
; +} diff --git a/app/src/app/ui-review-charts/pools.ts b/app/src/app/ui-review-charts/pools.ts new file mode 100644 index 0000000..254cae1 --- /dev/null +++ b/app/src/app/ui-review-charts/pools.ts @@ -0,0 +1,15 @@ +import type { ChartPool } from "@/lib/launchpad/chart-pool"; + +// Public pool identities verified against Openlaunch's API: Base/Robinhood on +// September 13, 2026; Arc on September 16, 2026. +// Market data is fetched at request time; no synthetic prices or candles. +export const REVIEW_POOLS: (ChartPool & { id: string; name: string; symbol: string; launchedAt: string; hasTrades?: boolean })[] = [ + { id: "solv", name: "SolvScore", symbol: "SOLV", chain: "base", token: "0x9aee340b42365372b525f075e60672d8fe375655", poolId: "0x554921d5edbe82d5799488ef0ccef42f527bf0951643c0eb43b4f046b4d2abd4", quote: "0x0000000000000000000000000000000000000000", launchedAt: "2026-09-11T19:03:07Z" }, + { id: "rfly", name: "RFLY", symbol: "RFLY", chain: "robinhood", token: "0x8f4a550011964ca31c4633dd914c5c66c7cbe989", poolId: "0x50f63d4d4d145e43b7a6e3b198f4e2f6a00f942c6ed822bbb525921c4b3bf4b6", quote: "0x0000000000000000000000000000000000000000", launchedAt: "2026-09-11T23:50:04Z" }, + { id: "ocat", name: "opencat", symbol: "OCAT", chain: "arc", token: "0xf21faee473897fd99ad1a8c56024efc8ffb97635", poolId: "0x0ca4514e648957422315f458df1a13d3a27ad57a8a375a340877b0dfe8616b34", quote: "0x3600000000000000000000000000000000000000", launchedAt: "2026-09-16T13:57:51Z" }, + { id: "sky", name: "Clear sky", symbol: "SKY", chain: "base", token: "0xf9473202c0766b11f879806099d5e260b3e1a250", poolId: "0x4ed5e6961940362b520ba9e1c97239a12f62c4b49c4c21598cd807b04a74d75b", quote: "0x0000000000000000000000000000000000000000", launchedAt: "2026-09-07T12:12:07Z" }, + { id: "quiver", name: "Quiver RH", symbol: "QUIVER", chain: "robinhood", token: "0x5d4a3ed0f20d5b73f88d6ebf21b45c6cd4835655", poolId: "0x1c33b20faa470ac1bf8d4d33e875ce56c3ac8854a24ac72e4f1160ce7522c6e7", quote: "0x232b8ed6377be97813853b0ac104c4cda8378d1b", launchedAt: "2026-09-07T03:48:11Z" }, + { id: "unpriced", name: "No USD price", symbol: "SKY", chain: "base", token: "0x6a22012a216250723ced7eba9f13fe8b60e5fea5", poolId: "0x767d79e5c65b85273b9d989d9303e54a119db6fbb7aeb06acac48cbc034e4909", quote: "0x5f980dcfc4c0fa3911554cf5ab288ed0eb13dba3", launchedAt: "2026-09-08T09:53:19Z" }, + { id: "inverted", name: "Reversed listing", symbol: "LJB", chain: "robinhood", token: "0xf0c81b03a33463272a5466afaed628989a030f82", poolId: "0x0ad3c579eee0c348603e8a5c0b81f590c5d489ae16b153812b05996da730a7b4", quote: "0xd1b0d44e4f6ed940fcc7a9f59bf30daf62ccfe3d", launchedAt: "2026-09-11T20:00:27Z" }, + { id: "new", name: "No trades", symbol: "FLY", chain: "robinhood", token: "0xbc57a682e0c44c9f27da6f41d6d5986569a76685", poolId: "0xae5cc300cde30a853a86a57e0ac6d9fc5eab25a21f00ae984e6fbf3db95f810f", quote: "0x0000000000000000000000000000000000000000", launchedAt: "2026-09-12T15:46:36Z", hasTrades: false }, +]; diff --git a/app/src/components/launchpad/PriceChart.tsx b/app/src/components/launchpad/PriceChart.tsx index 17f9ed2..ff81cb0 100644 --- a/app/src/components/launchpad/PriceChart.tsx +++ b/app/src/components/launchpad/PriceChart.tsx @@ -11,7 +11,8 @@ import { useLive } from "./LiveProvider"; import TradingChart from "./TradingChart"; /** One bounded history request, then shared-clock refreshes. No extra poller. */ -export default function PriceChart({ chain, token, symbol, launchedAt }: { chain: ChainKey; token: string; symbol: string; launchedAt: string }) { +export default function PriceChart({ chain, token, symbol, launchedAt, review = false, onTradesFound }: { chain: ChainKey; token: string; symbol: string; launchedAt: string; review?: boolean; onTradesFound?: () => void }) { + const reviewMode = process.env.NODE_ENV === "development" && review; const launchT = Math.floor(new Date(launchedAt).getTime() / 1000); const [interval, setInterval] = useState(() => defaultInterval(nowMs() / 1000 - launchT)); const [range, setRange] = useState("auto"); @@ -42,12 +43,14 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain : range === "auto" ? undefined : chartRangeSelection(range, launchT, now).from; const query = new URLSearchParams({ chain, token, interval }); if (from !== undefined) query.set("from", String(from)); - if (address) query.set("wallet", address); - const response = await fetch(`/api/launch/candles?${query}`, { cache: "no-store", signal: controller.signal }); + if (address && !reviewMode) query.set("wallet", address); + const endpoint = reviewMode ? "/ui-review-charts/candles" : "/api/launch/candles"; + const response = await fetch(`${endpoint}?${query}`, { cache: "no-store", signal: controller.signal }); if (!response.ok) throw new Error(`Chart request failed (${response.status}).`); const payload = await response.json() as ChartPayload; if (id !== generation.current || controller.signal.aborted) return; ready.current = requestKey; + if (payload.baseline.hasPriorTrades || payload.candles.length) onTradesFound?.(); if (!tailOnly) lastFull.current = nowMs(); setFailure(null); setResult((current) => ({ key: requestKey, @@ -58,7 +61,7 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain } finally { if (id === generation.current) { fetching.current = false; setLoading(false); } } - }, [requestKey, chain, token, interval, range, address, launchT]); + }, [requestKey, chain, token, interval, range, address, launchT, reviewMode, onTradesFound]); useEffect(() => { const lifetime = generation; @@ -81,7 +84,7 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain return { setInterval(next); setRange("auto"); }} onRangeChange={(next) => { const selection = chartRangeSelection(next, launchT, Math.floor(nowMs() / 1000)); setRange(next); setInterval(selection.interval); }} onRefresh={() => void load()} />; diff --git a/app/src/components/launchpad/TokenChart.tsx b/app/src/components/launchpad/TokenChart.tsx new file mode 100644 index 0000000..0d2d1e6 --- /dev/null +++ b/app/src/components/launchpad/TokenChart.tsx @@ -0,0 +1,129 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useCallback, useEffect, useState } from "react"; +import { useTheme } from "next-themes"; +import { ArrowUpRight, ChartCandlestick, RefreshCw } from "lucide-react"; +import { ToggleGroup, ToggleGroupItem } from "@/components/vendor/toggle-group"; +import { isChartPool, type ChartPool } from "@/lib/launchpad/chart-pool"; +import { geckoChartUrl, type GeckoStatus } from "@/lib/launchpad/geckoterminal"; + +const OnchainChart = dynamic(() => import("./PriceChart"), { + ssr: false, + loading: () => , +}); +type Props = ChartPool & { symbol: string; launchedAt: string; hasTrades: boolean; review?: boolean }; +type Lookup = GeckoStatus | "checking" | "error"; +const fallbackReason: Record, string> = { + unlisted: "Gecko hasn’t indexed this pool yet.", + inverted: "Gecko lists the quote asset first, so we’re showing the launched token here.", + unpriced: "Gecko doesn’t have a usable USD price for this pool yet.", + error: "Gecko is temporarily unavailable.", +}; + +export default function TokenChart(props: Props) { + if (!isChartPool(props)) return ; + return ; +} + +function MarketChart({ symbol, chain, token, poolId, quote, launchedAt, hasTrades: initialHasTrades, review = false }: Props) { + const { resolvedTheme } = useTheme(); + const [lookup, setLookup] = useState("checking"); + const [attempt, setAttempt] = useState(0); + const [hasTrades, setHasTrades] = useState(initialHasTrades); + const [source, setSource] = useState<"auto" | "onchain">("auto"); + const pool = { chain, token, poolId, quote }; + const theme = resolvedTheme === "dark" ? "dark" : "light"; + const hosted = hasTrades && source === "auto" && lookup === "ready"; + const checking = hasTrades && source === "auto" && lookup === "checking"; + const url = geckoChartUrl(pool, theme, { interval: "15m", metric: "price" }); + const external = geckoChartUrl(pool, theme); + const onTradesFound = useCallback(() => setHasTrades(true), []); + const onFrameError = useCallback(() => setLookup("error"), []); + + useEffect(() => { + if (!hasTrades) return; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + let active = true; + const query = new URLSearchParams({ chain, token, pool: poolId, quote }); + void (async () => { + try { + const response = await fetch("/api/launch/chart-provider?" + query, { signal: controller.signal, cache: "no-store" }); + if (!response.ok) throw new Error("Lookup failed."); + const result: unknown = await response.json(); + if (!result || typeof result !== "object" || !("status" in result) + || !["ready", "unlisted", "inverted", "unpriced"].includes(String(result.status))) throw new Error("Invalid response."); + if (active) setLookup(result.status as GeckoStatus); + } catch { + if (active) setLookup("error"); + } finally { + clearTimeout(timeout); + } + })(); + return () => { active = false; clearTimeout(timeout); controller.abort(); }; + }, [chain, token, poolId, quote, attempt, hasTrades]); + + function retry() { + setSource("auto"); + setLookup("checking"); + setAttempt((value) => value + 1); + } + + return
+
+

Market chart

+
+ { if (values[0] === "onchain") setSource("onchain"); else if (values[0] === "gecko") retry(); }} + className="border-0 bg-transparent p-0"> + Advanced + On-chain + + + + GeckoTerminal + +
+
+ + {hosted ? <> + +

Chart and market data by GeckoTerminal. Blank chart? Switch to On-chain above. Reloading, switching sources or changing the site theme may reset drawings.

+ : checking ? + : <> + {hasTrades && source === "auto" && lookup !== "ready" && lookup !== "checking" + ?

{fallbackReason[lookup]} Showing Openlaunch’s indexed history.

+ :

Direct from indexed Uniswap v4 swaps. No external chart required.

} + + } +
; +} + +function ChartMessage({ title, description }: { title: string; description: string }) { + return
+

{title}

{description}

+
; +} + +function ChartFrame({ url, symbol, onError }: { url: string; symbol: string; onError: () => void }) { + // The cross-origin load event proves navigation, not rendered candles. Keep + // On-chain available and never replace a healthy frame on a blind timeout. + const [state, setState] = useState<"loading" | "loaded" | "slow">("loading"); + useEffect(() => { + const timeout = setTimeout(() => setState((current) => current === "loading" ? "slow" : current), 15_000); + return () => clearTimeout(timeout); + }, []); + + return <> + {state !== "loaded" &&

{state === "slow" ? "Gecko is taking longer than expected. On-chain history is available above." : "Loading GeckoTerminal…"}

} +