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
2 changes: 1 addition & 1 deletion app/src/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ GITLAWB (Robinhood, 18 dec): 0xd1b0d44E4f6ed940fcC7A9F59Bf30Daf62cCFe3D (LayerZe
factory.launch((name, symbol, metadataURI, quote, supply=0, startTick, lpFee, salt, recipients))
startTick 184200 ≈ 10 ETH FDV for 1B supply (raw price = 1.0001^tick token-wei per quote unit; for USDG or USDC use ~391400 ≈ $10k FDV).
ERC20 quote: token must sort above the quote address → call factory.findSalt(launcher, baseSalt, name, symbol, 0, metadataURI, quote, 64) first.
Optional metadata: POST ${SITE_URL}/api/launch/meta {chain, launcher, salt, name, symbol, description?, image_url?, website?, x_handle?} → {uri, token}; pass uri as metadataURI.
Optional metadata: POST ${SITE_URL}/api/launch/meta {chain, launcher, salt, name, symbol, description?, image_url?, website?, x_handle?} → {uri, token}; pass uri as metadataURI. x_handle takes a handle (foo, @foo) or an x.com / twitter.com profile link; the bare handle is stored.
Recommended: buy a little right after launch (a Universal Router swap on the new pool; the site's form suggests ≈ $25). A token with no
holder and no price move reads as dead on screeners and sits under "quiet launches" on the home page until an outside wallet trades it.

Expand Down
9 changes: 5 additions & 4 deletions app/src/app/t/[chain]/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { memo } from "@/lib/launchpad/memo";
import { ago, nowMs } from "@/lib/launchpad/time";
import { getLaunch, getSwaps } from "@/lib/launchpad/queries";
import { ethUsd } from "@/lib/launchpad/ethPrice";
import { NATIVE, SWAP_SITES, TICK_SPACING, quoteKeyOf, uniswapSwapUrl, type Quote } from "@/lib/launchpad/config";
import { NATIVE, SWAP_SITES, TICK_SPACING, quoteKeyOf, type Quote } from "@/lib/launchpad/config";
import { GITLAWB_SITE } from "@/lib/launchpad/gitlawb";
import GitlawbBadge from "@/components/launchpad/GitlawbBadge";
import { fmtCompact, fmtPrice, fmtQuote, fmtUsd, pipsToPct } from "@/lib/launchpad/math";
Expand Down Expand Up @@ -80,6 +80,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s

const supplyLabel = fmtCompact(Number(BigInt(l.supply)) / 1e18, 0);
const feeRoute = mode === "free" ? "No trading fee" : `${pipsToPct(l.lp_fee)} trading fee → ${mode === "burn" ? "burned" : mode === "split" ? "beneficiaries" : "beneficiary"}`;
const swapSite = SWAP_SITES[chain];
const utility = "inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-line px-2.5 text-xs text-muted hover:border-line-strong hover:text-ink";

// Facts-only structured data (on-chain fields + creator metadata, no scores).
Expand Down Expand Up @@ -151,11 +152,11 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s
about={<section className="p-5">
<h2 className="text-base font-semibold text-ink">Behind {l.symbol}</h2>
<p className="mt-2 max-w-xl whitespace-pre-wrap break-words text-sm leading-relaxed text-body text-pretty">{l.description || "The creator has not added a description yet. The contract details below are recorded on-chain."}</p>
<div className="mt-4 flex flex-wrap gap-2">
{l.website || l.x_handle || swapSite ? <div className="mt-4 flex flex-wrap gap-2">
{l.website ? <a href={l.website} target="_blank" rel="noreferrer nofollow" className={utility}><Globe size={13} /> Website ↗</a> : null}
{l.x_handle ? <a href={`https://x.com/${l.x_handle}`} target="_blank" rel="noreferrer nofollow" className={utility}>@{l.x_handle} ↗</a> : null}
<a href={uniswapSwapUrl(chain, l.token)} target="_blank" rel="noreferrer" className={utility}>Open in {SWAP_SITES[chain].name} ↗</a>
</div>
{swapSite ? <a href={swapSite.url(l.token)} target="_blank" rel="noreferrer" className={utility}>Open in {swapSite.name} ↗</a> : null}
</div> : null}
<dl className="mt-5 divide-y divide-line border-y border-line text-xs">
<Row k="Creator" v={<A href={explorerAddress(chain, l.launcher)}>{shortAddr(l.launcher)} ↗</A>} />
<Row k="Token contract" v={<A href={explorerAddress(chain, l.token)}>{shortAddr(l.token)} ↗</A>} />
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/launchpad/EditTokenSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export default function EditTokenSheet({ chain, token, symbol, initial, onClose,
</div>
<div>
<label className={label} htmlFor="e-x">X</label>
<input id="e-x" className={input} value={f.x_handle} onChange={(e) => setF({ ...f, x_handle: e.target.value })} placeholder="@handle" />
<input id="e-x" className={input} value={f.x_handle} onChange={(e) => setF({ ...f, x_handle: e.target.value })} onBlur={() => { if (v.ok && v.value.x_handle) setF((p) => ({ ...p, x_handle: `@${v.value.x_handle}` })); }} placeholder="@handle or x.com link" autoCapitalize="none" spellCheck={false} />
</div>
</div>
{!v.ok ? <p className="text-xs text-warm-ink">{v.error}</p> : null}
Expand Down
5 changes: 4 additions & 1 deletion app/src/components/launchpad/LaunchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { BUY_PRESETS, defaultFirstBuy, gasReserveInQuote, suggestFirstBuy } from
import { getFirstBuyDeclined, getFirstBuyDeclinedServer, setFirstBuyDeclined, subscribeFirstBuyDeclined } from "@/lib/launchpad/first-buy-session";
import { encodeV4ExactInSingle, type PoolKey } from "@/lib/launchpad/swap";
import { GITLAWB_SITE } from "@/lib/launchpad/gitlawb";
import { parseXHandle } from "@/lib/launchpad/xHandle";
import { CHAINS, CHAIN_LABELS, CHAIN_KEYS, DEFAULT_CHAIN, BUILDER_DATA_SUFFIX, explorerTx, shortAddr, type ChainKey } from "@/lib/chainPublic";
import { friendlyError } from "@/lib/errors";
import { Spinner } from "@/components/Skeleton";
Expand Down Expand Up @@ -274,6 +275,8 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D
if (quoteKey === "gitlawb" && quote.usd === null && !customMcap.trim() && startTick === null) errors.push("GITLAWB price unavailable right now: enter a custom starting market cap in GITLAWB, or reload.");
if (image && !/^https:\/\//.test(image.trim())) errors.push("Image must be an https URL.");
if (website && !/^https:\/\//.test(website.trim())) errors.push("Website must be an https URL.");
const xParsed = parseXHandle(x);
if (!xParsed.ok) errors.push("X: enter a handle or an x.com link.");
const split = useMemo(() => buildRecipients(rows), [rows]);
if (feePips > 0 && beneficiary === "custom") errors.push(...split.errors);
if (initialBuyRaw === undefined) errors.push(`First buy: enter an amount in ${quote.symbol}, or leave it empty.`);
Expand Down Expand Up @@ -647,7 +650,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D
<label className={label} htmlFor="x">
X <span className="text-muted font-normal">· optional</span>
</label>
<input id="x" className={input} value={x} onChange={(e) => setX(e.target.value)} placeholder="@handle" />
<input id="x" className={input} value={x} onChange={(e) => setX(e.target.value)} onBlur={() => { if (xParsed.ok && xParsed.handle) setX(`@${xParsed.handle}`); }} placeholder="@handle or x.com link" autoCapitalize="none" spellCheck={false} />
</div>
</div>
</section>
Expand Down
9 changes: 2 additions & 7 deletions app/src/lib/launchpad/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,8 @@ export function browserRpc(key: ChainKey): string {
return DEV_RPC[key]?.trim() || `${SITE_URL}/api/rpc?chain=${key}`;
}

/** Where a token trades outside this site, per chain. */
export const SWAP_SITES: Record<ChainKey, { name: string; url: (token: string) => string }> = {
/** Where a token trades outside this site, per chain. A chain without an entry shows no outside link. */
export const SWAP_SITES: Partial<Record<ChainKey, { name: string; url: (token: string) => string }>> = {
base: { name: "Uniswap", url: (t) => `https://app.uniswap.org/swap?chain=base&outputCurrency=${t}` },
robinhood: { name: "pools.trade", url: (t) => `https://pools.trade/token/${t}` },
arc: { name: "Uniswap", url: (t) => `https://app.uniswap.org/swap?chain=arc&outputCurrency=${t}` },
};

export function uniswapSwapUrl(key: ChainKey, token: string): string {
return SWAP_SITES[key].url(token);
}
13 changes: 13 additions & 0 deletions app/src/lib/launchpad/creator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ test("validateEdit: https only, no private hosts, no HTML, handle format", () =>
assert.equal(validateEdit({}).ok, true, "all fields optional");
});

test("validateEdit: an X link normalizes to the handle, so the signed message is the same either way", () => {
const link = validateEdit({ x_handle: "https://x.com/abc_1?s=21" });
const bare = validateEdit({ x_handle: "@abc_1" });
assert.ok(link.ok && bare.ok);
if (!link.ok || !bare.ok) return;
assert.equal(link.value.x_handle, "abc_1");
const p = { chain: "base", token: "0xab", wallet: "0xcd", nonce: "n", expiresAt: 0 };
assert.equal(buildEditMessage({ ...p, fields: link.value }), buildEditMessage({ ...p, fields: bare.value }));
assert.match(buildEditMessage({ ...p, fields: link.value }), /^x: abc_1$/m);
assert.equal(validateEdit({ x_handle: "https://evil.com/abc_1" }).ok, false, "other hosts rejected");
assert.equal(validateEdit({ x_handle: "a".repeat(16) }).ok, false, "too long is an error, not a truncation");
});

test("isNonce accepts 32 hex chars only", () => {
assert.equal(isNonce("0123456789abcdef0123456789abcdef"), true);
assert.equal(isNonce("0123456789ABCDEF0123456789abcdef"), false);
Expand Down
7 changes: 5 additions & 2 deletions app/src/lib/launchpad/editAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* 3. client POSTs the edit + nonce + signature → server verifies signature, nonce, expiry,
* and that the signer is the on-chain launcher
*/
import { parseXHandle } from "./xHandle.ts";

export const EDIT_TTL_MS = 5 * 60_000;
export const EDIT_DOMAIN = "openlaunch.lol";

Expand Down Expand Up @@ -56,8 +58,9 @@ export function validateEdit(f: Partial<Record<keyof EditFields, unknown>>): { o
const website = url(f.website);
if (image_url === null) return { ok: false, error: "image: https URL only" };
if (website === null) return { ok: false, error: "website: https URL only" };
const x_handle = str(f.x_handle, 16).replace(/^@/, "");
if (x_handle && !/^[A-Za-z0-9_]{1,15}$/.test(x_handle)) return { ok: false, error: "x: letters, digits, _" };
const x = parseXHandle(f.x_handle);
if (!x.ok) return { ok: false, error: x.error };
const x_handle = x.handle;
if (/<[a-z!/]/i.test(description)) return { ok: false, error: "description: no HTML" };
return { ok: true, value: { description, image_url, website, x_handle } };
}
30 changes: 30 additions & 0 deletions app/src/lib/launchpad/editServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,36 @@ test("applySignedEdit: valid signature consumes the nonce and writes metadata",
assert.equal(m.metaInserted, true, "metadata should be written on success");
});

test("applySignedEdit: an X profile link in the fields is accepted and written", async () => {
resetMock();
seedNonce();
seedLauncher();
const m = getMock();
m.verifyShouldThrow = false;
m.verifyResult = true;

const r = await ed.applySignedEdit(editRequest({ fields: { description: "hello", x_handle: "https://x.com/openlaunch_lol?s=21" } }));

assert.equal(r.ok, true, "should succeed");
assert.equal(m.metaInserted, true, "metadata should be written");
});

test("applySignedEdit: a link to another site in the X field is a 400 and leaves the nonce alone", async () => {
resetMock();
seedNonce();
seedLauncher();
const m = getMock();
m.verifyShouldThrow = false;
m.verifyResult = true;

const r = await ed.applySignedEdit(editRequest({ fields: { x_handle: "https://evil.com/openlaunch_lol" } }));

assert.equal(r.ok, false, "should fail");
assert.equal(r.status, 400);
assert.equal(m.nonceRows[NONCE].used_at, null, "nonce must not be consumed");
assert.equal(m.metaInserted, false, "metadata must not be written");
});

test("applySignedEdit: metadata write failure rolls back the nonce consume (transaction)", async () => {
resetMock();
seedNonce();
Expand Down
16 changes: 16 additions & 0 deletions app/src/lib/launchpad/metaShared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,19 @@ test("metaWriteDecision: insert when new, same on an identical resend, conflict
assert.equal(metaWriteDecision({ ...row, launcher: "0x" + "1".repeat(40) }, inc), "conflict", "different launcher");
assert.equal(metaWriteDecision({ ...row, image_url: "https://evil/x.png" }, inc), "conflict", "attacker image");
});

test("validateMeta: x_handle takes a handle or an x.com link and stores the bare handle", () => {
const link = validateMeta({ ...base, x_handle: "https://twitter.com/clear_sky/status/1" });
const bare = validateMeta({ ...base, x_handle: "@clear_sky" });
assert.ok(link.ok && bare.ok);
assert.equal(link.ok && link.value.x_handle, "clear_sky");
assert.equal(validateMeta(base).ok && (validateMeta(base) as { value: { x_handle?: string } }).value.x_handle, undefined, "empty stays unset");
assert.equal(validateMeta({ ...base, x_handle: "https://evil.com/clear_sky" }).ok, false, "other hosts rejected");
assert.equal(validateMeta({ ...base, x_handle: "a".repeat(16) }).ok, false, "too long is an error, not a truncation");
// a retry that sends the link again matches the row saved from it (and from the bare handle)
const row = { launcher: L.toLowerCase(), name: "Clear Sky", symbol: "SKY", description: null, image_url: null, website: null, x_handle: "clear_sky" };
if (link.ok && bare.ok) {
assert.equal(metaWriteDecision(row, link.value), "same");
assert.equal(metaWriteDecision(row, bare.value), "same");
}
});
6 changes: 4 additions & 2 deletions app/src/lib/launchpad/metaShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/
import { isAddress, type Hex } from "viem";
import { SITE_URL, isChainKey, type ChainKey, CHAIN_KEY_PATTERN } from "../chainPublic.ts";
import { parseXHandle } from "./xHandle.ts";

export type MetaInput = { chain: ChainKey; launcher: string; salt: Hex; meta_key: Hex; name: string; symbol: string; description?: string; image_url?: string; website?: string; x_handle?: string };
export const LIMITS = { name: 32, symbol: 10, description: 280 } as const;
Expand Down Expand Up @@ -41,8 +42,9 @@ export function validateMeta(m: Partial<MetaInput> & { meta_key?: string }): { o
const website = url(m.website);
if (image_url === null) return { ok: false, error: "image: https URL only" };
if (website === null) return { ok: false, error: "website: https URL only" };
const x_handle = (m.x_handle ?? "").trim().replace(/^@/, "").slice(0, 15) || undefined;
if (x_handle && !/^[A-Za-z0-9_]{1,15}$/.test(x_handle)) return { ok: false, error: "x: letters, digits, _" };
const x = parseXHandle(m.x_handle);
if (!x.ok) return { ok: false, error: x.error };
const x_handle = x.handle || undefined;
return { ok: true, value: { chain: m.chain, launcher: m.launcher, salt: m.salt as Hex, meta_key: meta_key.toLowerCase() as Hex, name, symbol, description, image_url, website, x_handle } };
}

Expand Down
22 changes: 22 additions & 0 deletions app/src/lib/launchpad/swap-sites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { SWAP_SITES } from "./config.ts";

const T = "0x" + "11".repeat(20);

test("SWAP_SITES: Base and Arc link out to Uniswap; Robinhood Chain has no outside link", () => {
assert.equal(SWAP_SITES.base?.name, "Uniswap");
assert.equal(SWAP_SITES.base?.url(T), `https://app.uniswap.org/swap?chain=base&outputCurrency=${T}`);
assert.equal(SWAP_SITES.arc?.url(T), `https://app.uniswap.org/swap?chain=arc&outputCurrency=${T}`);
assert.equal(SWAP_SITES.robinhood, undefined);
});

// Source contract: the token page renders the link only for a chain that has one.
test("the token page skips the outside swap link when the chain has none", () => {
const page = readFileSync(new URL("../../app/t/[chain]/[token]/page.tsx", import.meta.url), "utf8");
assert.match(page, /const swapSite = SWAP_SITES\[chain\];/);
assert.match(page, /\{swapSite \? <a href=\{swapSite\.url\(l\.token\)\}/);
assert.match(page, /\{l\.website \|\| l\.x_handle \|\| swapSite \? <div/, "no empty links row");
assert.doesNotMatch(page, /pools\.trade/);
});
93 changes: 93 additions & 0 deletions app/src/lib/launchpad/xHandle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { parseXHandle } from "./xHandle.ts";

const handle = (input: unknown) => {
const r = parseXHandle(input);
return r.ok ? r.handle : null;
};

test("parseXHandle: a bare handle, with or without @, surrounding space trimmed", () => {
assert.equal(handle("foo"), "foo");
assert.equal(handle("@foo_1"), "foo_1");
assert.equal(handle(" @Foo "), "Foo", "case is kept as typed");
assert.equal(handle("a".repeat(15)), "a".repeat(15));
});

test("parseXHandle: empty and non-string input mean no handle, not an error", () => {
assert.equal(handle(""), "");
assert.equal(handle(" "), "");
assert.equal(handle(undefined), "");
assert.equal(handle(null), "");
assert.equal(handle(42), "");
});

test("parseXHandle: profile links on x.com and twitter.com, as pasted from the app", () => {
for (const link of [
"https://x.com/foo",
"http://x.com/foo",
"x.com/foo",
"https://www.x.com/foo",
"https://mobile.x.com/foo",
"https://twitter.com/foo",
"www.twitter.com/foo",
"https://mobile.twitter.com/foo",
"HTTPS://X.COM/foo",
"https://x.com/foo/",
"https://x.com/foo?s=21&t=abc",
"https://x.com/foo#top",
"https://x.com/foo/status/1234567890",
"https://x.com/@foo",
]) assert.equal(handle(link), "foo", link);
});

test("parseXHandle: other hosts, lookalikes and non-web schemes are rejected", () => {
for (const bad of [
"https://evil.com/foo",
"https://x.com.evil.com/foo",
"https://notx.com/foo",
"https://x.com@evil.com/foo",
"https://t.co/foo",
"javascript://x.com/foo",
"ftp://x.com/foo",
]) assert.equal(handle(bad), null, bad);
});

test("parseXHandle: links that are not a profile are rejected", () => {
for (const bad of ["https://x.com", "https://x.com/", "x.com", "https://x.com/home", "https://x.com/i/communities/1", "https://x.com/Intent/post?text=hi", "x.com/search?q=foo"]) {
assert.equal(handle(bad), null, bad);
}
});

// Every handle the old validators accepted must still pass, or a saved token's edit sheet would refuse to open clean.
test("parseXHandle: the page list applies to links only; a typed handle is never second-guessed", () => {
for (const ok of ["home", "@share", "i", "Search", "_", "a_b_c_d_e_f_g_h"]) assert.equal(handle(ok), ok.replace(/^@/, ""), ok);
});

test("parseXHandle: tricks that try to smuggle another host past the check", () => {
for (const bad of ["https://x.com\\@evil.com/foo", "https://x.com%2F@evil.com/foo", "https://evil.com/x.com/foo", "https://evil.com#x.com/foo", "https://x.com:8443@evil.com/foo", "//evil.com/foo", "https://x.com/%66oo", "https://x.com/foo bar"]) {
assert.equal(handle(bad), null, bad);
}
});

test("parseXHandle: bad handles are rejected, never silently truncated", () => {
assert.equal(handle("bad handle!"), null);
assert.equal(handle("a".repeat(16)), null, "16 characters");
assert.equal(handle(`https://x.com/${"a".repeat(16)}`), null);
assert.equal(handle("foo-bar"), null);
assert.equal(handle("<script>"), null);
assert.equal(handle(`https://x.com/foo?${"a".repeat(300)}`), null, "oversized input");
});

// Source contracts: both forms route the field through the shared parser and say what it accepts.
test("the launch form and the edit sheet accept a link in the X field", () => {
const launch = readFileSync(new URL("../../components/launchpad/LaunchForm.tsx", import.meta.url), "utf8");
const edit = readFileSync(new URL("../../components/launchpad/EditTokenSheet.tsx", import.meta.url), "utf8");
assert.match(launch, /const xParsed = parseXHandle\(x\);/);
assert.match(launch, /if \(!xParsed\.ok\) errors\.push\("X: enter a handle or an x\.com link\."\);/);
for (const form of [launch, edit]) {
assert.match(form, /placeholder="@handle or x\.com link"/);
assert.doesNotMatch(form, /placeholder="@handle"/);
}
});
Loading
Loading