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
47 changes: 47 additions & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,53 @@ 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.

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 seven real pool identities
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
24 changes: 24 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,24 @@
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);
});
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 @@ -125,7 +125,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
51 changes: 51 additions & 0 deletions app/src/app/ui-review-charts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 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 |
| 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.
- 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 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.
35 changes: 35 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,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import { GET } from "./route";
import { REVIEW_POOLS } from "../pools";

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.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);
query.set("token", pool.token);
assert.equal((await GET(request())).status, 200);
assert.equal(upstream.mock.callCount(), 1);
} 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 });
}
}
41 changes: 41 additions & 0 deletions app/src/app/ui-review-charts/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <main className="mx-auto min-w-0 max-w-[1600px] px-4 py-6 sm:px-6 lg:px-8">
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-ink">Chart preview</h1>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted">The token-page chart with real Openlaunch pools. Check both supported chains and unindexed-pool states. No trades or wallet actions.</p>
</div>
<a href={`https://openlaunch.lol/t/${pool.chain}/${pool.token}`} target="_blank" rel="noreferrer" className="inline-flex min-h-9 items-center gap-1.5 text-xs text-body hover:text-ink">View live token<ArrowUpRight size={14} aria-hidden /></a>
</header>
<nav aria-label="Example pools" className="mb-5 flex flex-wrap gap-2">
{REVIEW_POOLS.map((item) => <Link key={item.id} href={`/ui-review-charts?pool=${item.id}`} prefetch={false} aria-current={item.id === pool.id ? "page" : undefined}
className={`ui-pressable flex min-h-11 items-center gap-2 rounded-lg px-3 text-sm ${item.id === pool.id ? "bg-line text-ink" : "text-muted hover:bg-card hover:text-ink"}`}>
{item.name}<span className="text-xs text-muted">{CHAIN_SHORT[item.chain]}</span>
</Link>)}
</nav>
<TokenChart key={pool.id} chain={pool.chain} token={pool.token} poolId={pool.poolId} quote={pool.quote} symbol={pool.symbol} launchedAt={pool.launchedAt} hasTrades={pool.hasTrades ?? true} review />
<details className="mt-5 border-t border-line pt-4 text-xs leading-relaxed text-muted">
<summary className="w-fit cursor-pointer py-2 text-body">Pool identity and trial notes</summary>
<dl className="mt-3 space-y-2">
<div><dt className="text-body">Exact Uniswap v4 pool</dt><dd className="mt-1 break-all font-mono">{pool.poolId}</dd></div>
<div><dt className="text-body">Token contract</dt><dd className="mt-1 break-all font-mono">{pool.token}</dd></div>
</dl>
<p className="mt-3 max-w-3xl">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.</p>
</details>
</main>;
}
13 changes: 13 additions & 0 deletions app/src/app/ui-review-charts/pools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ChartPool } from "@/lib/launchpad/chart-pool";

// Public pool identities verified against Openlaunch's API, September 13, 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: "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 },
];
Loading
Loading