diff --git a/app/src/app/llms.txt/route.ts b/app/src/app/llms.txt/route.ts index da9f5679..b797f6a6 100644 --- a/app/src/app/llms.txt/route.ts +++ b/app/src/app/llms.txt/route.ts @@ -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. diff --git a/app/src/app/t/[chain]/[token]/page.tsx b/app/src/app/t/[chain]/[token]/page.tsx index 0c498143..e9b51a50 100644 --- a/app/src/app/t/[chain]/[token]/page.tsx +++ b/app/src/app/t/[chain]/[token]/page.tsx @@ -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"; @@ -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). @@ -151,11 +152,11 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s about={

Behind {l.symbol}

{l.description || "The creator has not added a description yet. The contract details below are recorded on-chain."}

-
+ {l.website || l.x_handle || swapSite ?
{l.website ? Website ↗ : null} {l.x_handle ? @{l.x_handle} ↗ : null} - Open in {SWAP_SITES[chain].name} ↗ -
+ {swapSite ? Open in {swapSite.name} ↗ : null} +
: null}
{shortAddr(l.launcher)} ↗} /> {shortAddr(l.token)} ↗} /> diff --git a/app/src/components/launchpad/EditTokenSheet.tsx b/app/src/components/launchpad/EditTokenSheet.tsx index cb5634be..f4fba301 100644 --- a/app/src/components/launchpad/EditTokenSheet.tsx +++ b/app/src/components/launchpad/EditTokenSheet.tsx @@ -68,7 +68,7 @@ export default function EditTokenSheet({ chain, token, symbol, initial, onClose,
- setF({ ...f, x_handle: e.target.value })} placeholder="@handle" /> + 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} />
{!v.ok ?

{v.error}

: null} diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index b591e739..975cb1ee 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -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"; @@ -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.`); @@ -647,7 +650,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D - setX(e.target.value)} placeholder="@handle" /> + setX(e.target.value)} onBlur={() => { if (xParsed.ok && xParsed.handle) setX(`@${xParsed.handle}`); }} placeholder="@handle or x.com link" autoCapitalize="none" spellCheck={false} />
diff --git a/app/src/lib/launchpad/config.ts b/app/src/lib/launchpad/config.ts index 4d2de40b..76998a9a 100644 --- a/app/src/lib/launchpad/config.ts +++ b/app/src/lib/launchpad/config.ts @@ -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 string }> = { +/** Where a token trades outside this site, per chain. A chain without an entry shows no outside link. */ +export const SWAP_SITES: Partial 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); -} diff --git a/app/src/lib/launchpad/creator.test.ts b/app/src/lib/launchpad/creator.test.ts index e6f0e650..e1f22de8 100644 --- a/app/src/lib/launchpad/creator.test.ts +++ b/app/src/lib/launchpad/creator.test.ts @@ -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); diff --git a/app/src/lib/launchpad/editAuth.ts b/app/src/lib/launchpad/editAuth.ts index 9995ff22..7b44f3d4 100644 --- a/app/src/lib/launchpad/editAuth.ts +++ b/app/src/lib/launchpad/editAuth.ts @@ -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"; @@ -56,8 +58,9 @@ export function validateEdit(f: Partial>): { 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 } }; } diff --git a/app/src/lib/launchpad/editServer.test.ts b/app/src/lib/launchpad/editServer.test.ts index a2f8ff8a..8a5599f0 100644 --- a/app/src/lib/launchpad/editServer.test.ts +++ b/app/src/lib/launchpad/editServer.test.ts @@ -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(); diff --git a/app/src/lib/launchpad/metaShared.test.ts b/app/src/lib/launchpad/metaShared.test.ts index 263de5bb..6296ec53 100644 --- a/app/src/lib/launchpad/metaShared.test.ts +++ b/app/src/lib/launchpad/metaShared.test.ts @@ -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"); + } +}); diff --git a/app/src/lib/launchpad/metaShared.ts b/app/src/lib/launchpad/metaShared.ts index 2d8dadd0..88bde726 100644 --- a/app/src/lib/launchpad/metaShared.ts +++ b/app/src/lib/launchpad/metaShared.ts @@ -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; @@ -41,8 +42,9 @@ export function validateMeta(m: Partial & { 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 } }; } diff --git a/app/src/lib/launchpad/swap-sites.test.ts b/app/src/lib/launchpad/swap-sites.test.ts new file mode 100644 index 00000000..8d4132eb --- /dev/null +++ b/app/src/lib/launchpad/swap-sites.test.ts @@ -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 \? { + 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("