Skip to content
Open
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
50 changes: 50 additions & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=&quote=` 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
Expand Down
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" } });
}
}
4 changes: 2 additions & 2 deletions app/src/app/t/[chain]/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -135,7 +135,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s

<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_21rem] lg:gap-6 lg:grid-rows-[min-content_1fr]">
<div className="min-w-0 lg:col-start-1 lg:row-start-1">
<PriceChart key={`${chain}:${l.token}`} chain={chain} token={l.token} symbol={l.symbol} launchedAt={l.block_time} />
<TokenChart chain={chain} token={l.token} symbol={l.symbol} poolId={l.pool_id} quote={l.quote} launchedAt={l.block_time} hasTrades={l.buys + l.sells > 0} />
<dl className="mt-4 grid grid-cols-2 divide-x divide-line overflow-hidden rounded-xl border border-line bg-card sm:grid-cols-4">
<Stat k={priceUsd !== null ? "Price / USD" : `Price / ${quote.symbol}`} v={priceUsd !== null ? fmtUsd(priceUsd) : `${fmtPrice(l.price_quote)} ${quote.symbol}`} sub={`${fmtPrice(l.price_quote)} ${quote.symbol}`} />
<Stat k="Volume / all time" v={l.volume_usd !== null ? marketUsd(l.volume_usd) : fmtQuote(l.volume_quote, quote.decimals, quote.symbol)} sub={fmtQuote(l.volume_quote, quote.decimals, quote.symbol)} />
Expand Down
56 changes: 56 additions & 0 deletions app/src/app/ui-review-charts/README.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions app/src/app/ui-review-charts/candles/route.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
33 changes: 33 additions & 0 deletions app/src/app/ui-review-charts/candles/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Loading
Loading