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 00000000..858672af --- /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 00000000..9265b69c --- /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/components/Footer.module.css b/app/src/components/Footer.module.css index c3e55d5c..dd0d0cf9 100644 --- a/app/src/components/Footer.module.css +++ b/app/src/components/Footer.module.css @@ -35,6 +35,8 @@ .signature { display: block; width: fit-content; max-width: 100%; margin: 26px auto 24px; text-align: center; color: var(--color-ink); font-size: clamp(32px, 8.8vw, 112px); line-height: 1.08; font-weight: 700; letter-spacing: -.075em; white-space: nowrap; transition: color 180ms; } .signature:is(:hover, :focus-visible) { color: var(--color-brand); } .bottom { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 24px; padding-top: 16px; border-top: 1px solid var(--color-line); color: var(--color-muted); font-size: 11px; } +.credit { text-decoration: underline; text-decoration-color: var(--color-line-strong); text-underline-offset: 3px; } +.credit:hover { color: var(--color-ink); } .utilities { display: flex; align-items: center; gap: 24px; } .license, .backTop { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; } .license:hover, .backTop:hover { color: var(--color-ink); } diff --git a/app/src/components/Footer.tsx b/app/src/components/Footer.tsx index b3e6c8e6..901fe117 100644 --- a/app/src/components/Footer.tsx +++ b/app/src/components/Footer.tsx @@ -72,7 +72,8 @@ export default function Footer() {
-

Open source. Open participation.

+ {/* the chart library's licence credit, on every page that can show a chart */} +

Open source. Open participation. Charts by TradingView.

MIT licensed Back to top diff --git a/app/src/components/launchpad/PriceChart.tsx b/app/src/components/launchpad/PriceChart.tsx index 17f9ed26..bf1178e7 100644 --- a/app/src/components/launchpad/PriceChart.tsx +++ b/app/src/components/launchpad/PriceChart.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useAccount } from "wagmi"; +import { useHydratedAccount } from "@/lib/useHydratedAccount"; import { INTERVALS, bucketStart, defaultInterval, type Interval } from "@/lib/launchpad/candles"; import { chartRangeSelection, type ChartRange } from "@/lib/launchpad/chart-terminal"; import { mergeChartPayload, type ChartPayload } from "@/lib/launchpad/chart-payload"; @@ -18,7 +18,7 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain const [result, setResult] = useState<{ key: string; data: ChartPayload } | null>(null); const [loading, setLoading] = useState(true); const [failure, setFailure] = useState<{ key: string; message: string } | null>(null); - const { address } = useAccount(); + const { address } = useHydratedAccount(); const { subscribe } = useLive(); const requestKey = `${chain}:${token.toLowerCase()}:${interval}:${range}:${address?.toLowerCase() ?? ""}`; const request = useRef(null); @@ -83,6 +83,5 @@ export default function PriceChart({ chain, token, symbol, launchedAt }: { chain data={result?.key === requestKey ? result.data : null} loading={loading || (result?.key !== requestKey && failure?.key !== requestKey)} error={failure?.key === requestKey ? failure.message : null} hasWallet={Boolean(address)} onIntervalChange={(next) => { 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 00000000..b560a015 --- /dev/null +++ b/app/src/components/launchpad/TokenChart.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState } from "react"; +import { useTheme } from "next-themes"; +import { ArrowUpRight } 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"; +import PriceChart from "./PriceChart"; + +type Props = ChartPool & { symbol: string; launchedAt: string; hasTrades: boolean }; +type Lookup = "idle" | "checking" | GeckoStatus | "error"; +const notAvailable: Record, string> = { + unlisted: "GeckoTerminal has not indexed this pool yet.", + inverted: "GeckoTerminal lists this pool the other way round, so its chart would show the pair token.", + unpriced: "GeckoTerminal has no usable USD price for this pool yet.", + error: "GeckoTerminal could not be checked just now.", +}; + +/** + * The token chart. Our own chart from indexed Uniswap v4 swaps is the default; GeckoTerminal's hosted chart (with + * its drawing tools) is one tap away for pools it has indexed. The pool is checked only when someone asks for it, + * and only an exact match of pool, token and pair is ever shown as this token. + */ +export default function TokenChart(props: Props) { + const { chain, token, symbol, launchedAt, hasTrades } = props; + const { resolvedTheme } = useTheme(); + const [source, setSource] = useState<"onchain" | "gecko">("onchain"); + const [lookup, setLookup] = useState("idle"); + const valid = isChartPool(props); + const pool: ChartPool = { chain, token, poolId: props.poolId, quote: props.quote }; + const theme = resolvedTheme === "dark" ? "dark" : "light"; + + async function check() { + setLookup("checking"); + try { + const query = new URLSearchParams({ chain, token, pool: props.poolId, quote: props.quote }); + const response = await fetch(`/api/launch/chart-provider?${query}`, { cache: "no-store", signal: AbortSignal.timeout(10_000) }); + const result: unknown = response.ok ? await response.json() : null; + const status = result && typeof result === "object" && "status" in result ? String(result.status) : ""; + setLookup(["ready", "unlisted", "inverted", "unpriced"].includes(status) ? status as GeckoStatus : "error"); + } catch { + setLookup("error"); + } + } + + function pick(next: "onchain" | "gecko") { + setSource(next); + if (next === "gecko" && lookup !== "ready" && lookup !== "checking") void check(); + } + + const showGecko = source === "gecko" && lookup === "ready"; + return ( +
+ {valid && hasTrades ? ( +
+ {source === "gecko" && lookup !== "ready" && lookup !== "idle" ? ( +

{lookup === "checking" ? "Finding this pool on GeckoTerminal…" : `${notAvailable[lookup]} Showing our own chart.`}

+ ) : null} + { const next = values[0]; if (next === "onchain" || next === "gecko") pick(next); }}> + On-chain + GeckoTerminal + +
+ ) : null} + {showGecko ? ( +
+