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
79 changes: 79 additions & 0 deletions app/src/app/api/launch/chart-provider/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof identity> = {}, 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");
}
}
});
21 changes: 21 additions & 0 deletions app/src/app/api/launch/chart-provider/route.ts
Original file line number Diff line number Diff line change
@@ -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" } });
}
}
2 changes: 2 additions & 0 deletions app/src/components/Footer.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
3 changes: 2 additions & 1 deletion app/src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ export default function Footer() {
</Link>

<div className={styles.bottom}>
<p>Open source. Open participation.</p>
{/* the chart library's licence credit, on every page that can show a chart */}
<p>Open source. Open participation. Charts by <a href="https://www.tradingview.com/" target="_blank" rel="noreferrer" className={styles.credit}>TradingView</a>.</p>
<div className={styles.utilities}>
<a href={`${BRAND_GITHUB}/blob/main/LICENSE`} target="_blank" rel="noreferrer" className={styles.license}>MIT licensed <ArrowUpRight size={12} aria-hidden="true" /></a>
<a href="#site-top" className={styles.backTop}>Back to top <ArrowUp size={14} aria-hidden="true" /></a>
Expand Down
5 changes: 2 additions & 3 deletions app/src/components/launchpad/PriceChart.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<AbortController | null>(null);
Expand Down Expand Up @@ -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()} />;
}
88 changes: 88 additions & 0 deletions app/src/components/launchpad/TokenChart.tsx
Original file line number Diff line number Diff line change
@@ -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<Exclude<Lookup, "idle" | "checking" | "ready">, 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<Lookup>("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 (
<div className="min-w-0 space-y-2">
{valid && hasTrades ? (
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
{source === "gecko" && lookup !== "ready" && lookup !== "idle" ? (
<p role="status" className="mr-auto text-xs text-muted">{lookup === "checking" ? "Finding this pool on GeckoTerminal…" : `${notAvailable[lookup]} Showing our own chart.`}</p>
) : null}
<ToggleGroup aria-label="Chart source" value={[source]} onValueChange={(values) => { const next = values[0]; if (next === "onchain" || next === "gecko") pick(next); }}>
<ToggleGroupItem value="onchain" className="px-2.5 text-[11px]">On-chain</ToggleGroupItem>
<ToggleGroupItem value="gecko" className="px-2.5 text-[11px]">GeckoTerminal</ToggleGroupItem>
</ToggleGroup>
</div>
) : null}
{showGecko ? (
<section aria-label={`${symbol} chart by GeckoTerminal`} className="overflow-hidden rounded-2xl border border-line bg-paper">
<iframe
key={theme}
title={`${symbol} chart by GeckoTerminal`}
src={geckoChartUrl(pool, theme, { interval: "15m", metric: "price" })}
className="block h-[min(72svh,640px)] min-h-[440px] w-full border-0 bg-paper"
allow="clipboard-write"
allowFullScreen
referrerPolicy="no-referrer"
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-downloads"
/>
<p className="flex flex-wrap items-center justify-between gap-2 border-t border-line px-4 py-2.5 text-[11px] text-muted">
<span>Chart and market data by GeckoTerminal. Blank chart? Switch back to On-chain.</span>
<a href={geckoChartUrl(pool, theme)} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-body hover:text-ink">Open on GeckoTerminal<ArrowUpRight size={12} aria-hidden="true" /></a>
</p>
</section>
) : (
<PriceChart key={`${chain}:${token}`} chain={chain} token={token} symbol={symbol} launchedAt={launchedAt} />
)}
</div>
);
}
Loading
Loading