From 3d8f516dd6ee097c5e110f2d14bf7ef1e37fbc54 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Sat, 17 Oct 2026 22:32:11 +0800 Subject: [PATCH 1/3] feat(pairs): community pairs on Robinhood Chain Pairs were Base-only. Robinhood communities already launch on openlaunch (DottieLand has launches paired with DOTTIE) and could not apply. - PAIR_CHAINS = Base and Robinhood Chain. The free check runs unchanged there: GeckoTerminal's network id is the chain key, GoPlus covers 4663, and the v4 key, price and transfer probe read from Robinhood's RPC. - The fee stays TWIG on Base for a token on either chain. A burn is now matched to its application by id: it was matched on the burn's chain too, so a Robinhood application could never have been paid. - Every pair signature (applicant, review, admin) is checked on Base (PAIR_FEE_CHAIN), the chain the applicant pays on. - The apply form gets a chain picker (a check never carries over to the other chain); the application page signs with the application's chain and pays on Base; the admin panel lists, suspends and edits pairs on either chain; the review queue says which chain. - The pool check names each chain's own core quotes (USDG, ETH or GITLAWB on Robinhood). llms.txt and docs/pairs.md name both chains. --- app/src/app/api/pairs/precheck/route.ts | 4 +- app/src/app/llms.txt/route.ts | 9 ++-- app/src/app/pairs/apply/[id]/page.tsx | 3 +- .../components/launchpad/PairApplication.tsx | 25 +++++---- app/src/components/launchpad/PairApply.tsx | 42 +++++++++++---- .../components/launchpad/PairReviewAdmin.tsx | 4 +- app/src/components/launchpad/PairsAdmin.tsx | 52 +++++++++++-------- app/src/lib/launchpad/pairApply.test.ts | 45 +++++++++++++++- app/src/lib/launchpad/pairApply.ts | 2 +- app/src/lib/launchpad/pairApplyServer.ts | 12 +++-- app/src/lib/launchpad/pairCheck.ts | 10 +++- app/src/lib/launchpad/pairs.test.ts | 5 +- app/src/lib/launchpad/pairs.ts | 16 ++++-- app/src/lib/launchpad/pairsServer.ts | 7 +-- docs/pairs.md | 6 ++- 15 files changed, 169 insertions(+), 73 deletions(-) diff --git a/app/src/app/api/pairs/precheck/route.ts b/app/src/app/api/pairs/precheck/route.ts index 643b4bd..78cb460 100644 --- a/app/src/app/api/pairs/precheck/route.ts +++ b/app/src/app/api/pairs/precheck/route.ts @@ -3,7 +3,7 @@ import { isAddress } from "viem"; import { rateLimited } from "@/lib/launchpad/editServer"; import { clientIp, readJson } from "@/lib/profiles/http"; import { applicationsOpen, precheckToken } from "@/lib/launchpad/pairApplyServer"; -import { PAIR_CHAINS } from "@/lib/launchpad/pairs"; +import { PAIR_CHAINS, PAIR_CHAINS_TEXT } from "@/lib/launchpad/pairs"; export const dynamic = "force-dynamic"; @@ -16,7 +16,7 @@ export async function POST(req: Request) { if (rateLimited(`pairs-precheck:ip:${clientIp(req)}`, 6)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const b = await readJson(req); const chain = PAIR_CHAINS.find((c) => c === b?.chain); - if (!chain || typeof b?.address !== "string" || !isAddress(b.address)) return NextResponse.json({ error: "a Base token address" }, { status: 400 }); + if (!chain || typeof b?.address !== "string" || !isAddress(b.address)) return NextResponse.json({ error: `a token address on ${PAIR_CHAINS_TEXT}` }, { status: 400 }); try { return NextResponse.json(await precheckToken(chain, b.address), { headers: { "cache-control": "no-store" } }); } catch (e) { diff --git a/app/src/app/llms.txt/route.ts b/app/src/app/llms.txt/route.ts index bfb4de8..6076587 100644 --- a/app/src/app/llms.txt/route.ts +++ b/app/src/app/llms.txt/route.ts @@ -4,12 +4,13 @@ import { BRAND_DOMAIN, BRAND_GITHUB } from "@/lib/brand"; import { boostEnabled } from "@/lib/launchpad/boostServer"; import { BOOST_FLOOR_TWIG, BOOST_TOKEN, BOOST_WINDOW_H } from "@/lib/launchpad/boost"; import { applicationsOpen } from "@/lib/launchpad/pairApplyServer"; +import { PAIR_CHAINS, PAIR_CHAINS_TEXT } from "@/lib/launchpad/pairs"; /** How to apply for a community pair, only while applications are open: an agent must never burn for one that cannot exist yet. */ function pairApplySection(): string { if (!applicationsOpen()) return ""; - return `Listing is applied for at /pairs/apply (a free check, then a fee in TWIG sent to 0x…dEaD naming the application: amount = whole TWIG × 10^18 -+ 3 × 10^11 + application id; it pays for a review, not a listing, and is not refunded). Rules: https://github.com/Twigpine/openlaunch/blob/main/docs/pairs.md + return `Listing is applied for at /pairs/apply (a free check, then a fee in TWIG on Base, whichever chain the token is on, sent to 0x…dEaD naming the +application: amount = whole TWIG × 10^18 + 3 × 10^11 + application id; it pays for a review, not a listing, and is not refunded). Rules: https://github.com/Twigpine/openlaunch/blob/main/docs/pairs.md `; } @@ -97,13 +98,13 @@ Recommended: buy a little right after launch (a Universal Router swap on the new 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. ${boostSection()} -## Community pairs (Base) +## Community pairs (${PAIR_CHAINS_TEXT}) A launch may be paired with a listed community token: the site badges it and prices it in USD from one named pool (checked against that pool's 30-minute average). The launch form does not offer them (ETH, TWIG and stocks only): such a launch is made against the factory directly. The contracts accept any ERC-20 either way; an unlisted one shows as "Unlisted pair", unpriced. Each listing is checked hourly and suspended when its checks fail. - GET ${SITE_URL}/api/pairs (every pair: address, symbol, decimals, status, usd, price source) -Pages: /pairs, /pair/base/
. Launch with one against the factory exactly as with any ERC-20 quote (findSalt with the pair's address). +Pages: /pairs, /pair//
(chain: ${PAIR_CHAINS.join(" or ")}). Launch with one against the factory exactly as with any ERC-20 quote (findSalt with the pair's address). ${pairApplySection()} ## API - GET ${SITE_URL}/api/launch/list?chain=base|robinhood|arc&sort=live|new|mcap|volume|gainers|holders&window=1h|24h|all&limit=50 diff --git a/app/src/app/pairs/apply/[id]/page.tsx b/app/src/app/pairs/apply/[id]/page.tsx index 4fc1f44..26b6af3 100644 --- a/app/src/app/pairs/apply/[id]/page.tsx +++ b/app/src/app/pairs/apply/[id]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { CHAIN_LABELS } from "@/lib/chainPublic"; import { notFound } from "next/navigation"; import SectionIntro from "@/components/sections/SectionIntro"; import shell from "@/components/sections/SectionShell.module.css"; @@ -14,7 +15,7 @@ export default async function PairApplicationPage({ params }: { params: Promise< if (!app) notFound(); return (
- +
diff --git a/app/src/components/launchpad/PairApplication.tsx b/app/src/components/launchpad/PairApplication.tsx index f3710c5..9929a4c 100644 --- a/app/src/components/launchpad/PairApplication.tsx +++ b/app/src/components/launchpad/PairApplication.tsx @@ -7,21 +7,22 @@ import { getPublicClient, getWalletClient } from "wagmi/actions"; import type { Hex } from "viem"; import { useHydratedAccount } from "@/lib/useHydratedAccount"; import { btn, card, helper, input } from "@/components/ui"; -import { BUILDER_DATA_SUFFIX, CHAINS, explorerTx, shortAddr } from "@/lib/chainPublic"; +import { BUILDER_DATA_SUFFIX, CHAINS, CHAIN_LABELS, explorerAddress, explorerTx, shortAddr } from "@/lib/chainPublic"; import { ERC20_MIN_ABI, ERC20_TRANSFER_ABI } from "@/lib/launchpad/abi"; import { DEAD } from "@/lib/launchpad/config"; import { TWIG_ADDRESS, TWIG_WRAP_URL } from "@/lib/launchpad/twig"; import { nowMs } from "@/lib/launchpad/time"; import { nonce } from "@/lib/nonce"; import { friendlyError } from "@/lib/errors"; -import { pairPath } from "@/lib/launchpad/pairs"; +import { PAIR_FEE_CHAIN, pairPath } from "@/lib/launchpad/pairs"; import { PAIR_APPLY_COPY, buildPairApplyMessage, pairApplicationOf, proofPostText, proofPostUrl, type PairApplyAction } from "@/lib/launchpad/pairApply"; import { intentUrl } from "@/lib/profiles/xpost"; import type { PairApplicationPublic } from "@/lib/launchpad/pairApplyServer"; import PrecheckReport from "./PrecheckReport"; -const CHAIN = "base" as const; -const BASE = CHAINS[CHAIN]; +/** The fee is TWIG on Base and every signature here is made on Base, whatever chain the token is on. */ +const FEE_CHAIN = PAIR_FEE_CHAIN; +const BASE = CHAINS[FEE_CHAIN]; const group = (v: string | bigint) => BigInt(v).toLocaleString("en-US"); /** @@ -105,8 +106,8 @@ export default function PairApplication({ initial, open }: { initial: PairApplic const n = nonce(); const ts = nowMs(); const wallet = await getWalletClient(config, { chainId: BASE.id }); - const signature = await wallet.signMessage({ message: buildPairApplyMessage({ action, chain: CHAIN, address: app.address, wallet: address, nonce: n, ts, id: app.id, ...extra }) }); - const res = await fetch("/api/pairs/apply", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, chain: CHAIN, address: app.address, id: app.id, wallet: address, nonce: n, ts, signature, ...extra }) }); + const signature = await wallet.signMessage({ message: buildPairApplyMessage({ action, chain: app.chain, address: app.address, wallet: address, nonce: n, ts, id: app.id, ...extra }) }); + const res = await fetch("/api/pairs/apply", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, chain: app.chain, address: app.address, id: app.id, wallet: address, nonce: n, ts, signature, ...extra }) }); const d = (await res.json().catch(() => ({}))) as { error?: string }; if (!res.ok) throw new Error(d.error ?? `request failed (HTTP ${res.status})`); setMsg({ ok: true, text: action === "proof" ? "Proof post saved." : "Sent back for review." }); @@ -123,7 +124,7 @@ export default function PairApplication({ initial, open }: { initial: PairApplic if (!sent || busy) return; setBusy("recheck"); try { - await fetch(`/api/launch/sync?chain=${CHAIN}&tx=${sent}`, { method: "POST" }).catch(() => {}); + await fetch(`/api/launch/sync?chain=${FEE_CHAIN}&tx=${sent}`, { method: "POST" }).catch(() => {}); await reload(); } finally { setBusy(null); @@ -175,7 +176,7 @@ export default function PairApplication({ initial, open }: { initial: PairApplic setMsg({ ok: false, text: e instanceof Error && /reverted/.test(e.message) ? e.message : "Sent. Its confirmation is not in yet: this page updates when it is indexed." }); return; } - await fetch(`/api/launch/sync?chain=${CHAIN}&tx=${hash}`, { method: "POST" }).catch(() => {}); + await fetch(`/api/launch/sync?chain=${FEE_CHAIN}&tx=${hash}`, { method: "POST" }).catch(() => {}); setMsg({ ok: true, text: "Sent. It counts once indexed (seconds)." }); await reload(); void bal.refetch(); @@ -202,7 +203,9 @@ export default function PairApplication({ initial, open }: { initial: PairApplic {app.can_ask_again ? ( mine ? :

The applicant can ask for another review ({app.reviews_max - app.reviews_used} left).

) : null} -

Applied by {shortAddr(app.applicant)} · {app.reviews_used} of {app.reviews_max} reviews used.

+

+ {app.symbol} on {CHAIN_LABELS[app.chain]} ↗ · Applied by {shortAddr(app.applicant)} · {app.reviews_used} of {app.reviews_max} reviews used. +

@@ -248,12 +251,12 @@ export default function PairApplication({ initial, open }: { initial: PairApplic ) : ( app.status === "approved" && app.paid_usd < app.fee_usd * 0.98 ?

{app.paid_usd > 0 ? PAIR_APPLY_COPY.waivedRest(app.paid_usd) : PAIR_APPLY_COPY.waived}

:

Paid: ${app.paid_usd.toFixed(2)} of ${app.fee_usd}.

)} - {sent ?

Your transfer ↗

: null} + {sent ?

Your transfer ↗

: null} {app.burns.length ? (
    {app.burns.map((b) => (
  • - {group(b.twig)} TWIG · {b.usd !== null ? `$${b.usd.toFixed(2)} (${b.priced_by === "quote" ? "at the locked price" : "at the price when it landed"})` : "not valued yet"} · from {shortAddr(b.sender)} + {group(b.twig)} TWIG · {b.usd !== null ? `$${b.usd.toFixed(2)} (${b.priced_by === "quote" ? "at the locked price" : "at the price when it landed"})` : "not valued yet"} · from {shortAddr(b.sender)}
  • ))}
diff --git a/app/src/components/launchpad/PairApply.tsx b/app/src/components/launchpad/PairApply.tsx index f13b9fd..5e17550 100644 --- a/app/src/components/launchpad/PairApply.tsx +++ b/app/src/components/launchpad/PairApply.tsx @@ -7,26 +7,27 @@ import { getWalletClient } from "wagmi/actions"; import { isAddress } from "viem"; import { useHydratedAccount } from "@/lib/useHydratedAccount"; import { btn, card, helper, input, label } from "@/components/ui"; -import { CHAINS } from "@/lib/chainPublic"; +import { CHAINS, CHAIN_LABELS, type ChainKey } from "@/lib/chainPublic"; import { nowMs } from "@/lib/launchpad/time"; import { nonce } from "@/lib/nonce"; import { friendlyError } from "@/lib/errors"; -import { PAIR_DESCRIPTION_MAX } from "@/lib/launchpad/pairs"; +import { PAIR_CHAINS, PAIR_DESCRIPTION_MAX, PAIR_FEE_CHAIN } from "@/lib/launchpad/pairs"; import { PAIR_APPLY_COPY, buildPairApplyMessage, sourceLabel } from "@/lib/launchpad/pairApply"; import type { PairPrecheck } from "@/lib/launchpad/pairCheck"; +import { ChainLogo } from "./ChainLogo"; import ImageUpload from "./ImageUpload"; import PrecheckReport from "./PrecheckReport"; -const CHAIN = "base" as const; - /** - * Apply to list a community pair: the free check first (anyone, no wallet), then, once it passes, the application - * signed by a wallet with a verified X profile. Nothing is paid here: the fee is the next page's step. + * Apply to list a community pair: the token's chain, the free check first (anyone, no wallet), then, once it passes, + * the application signed by a wallet with a verified X profile. Nothing is paid here: the fee is the next page's step, + * in TWIG on Base whatever chain the token is on, and the signature is made on Base too (PAIR_FEE_CHAIN). */ export default function PairApply({ feeUsd }: { feeUsd: number }) { const router = useRouter(); const config = useConfig(); const { address } = useHydratedAccount(); + const [chain, setChain] = useState(PAIR_CHAINS[0]); const [token, setToken] = useState(""); const [check, setCheck] = useState(null); const [checking, setChecking] = useState(false); @@ -38,13 +39,21 @@ export default function PairApply({ feeUsd }: { feeUsd: number }) { const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); + function pickChain(c: ChainKey) { + if (c === chain) return; + setChain(c); + setCheck(null); + setSource(0); + setErr(null); + } + async function runCheck() { if (!isAddress(token.trim()) || checking) return; setChecking(true); setErr(null); setCheck(null); try { - const res = await fetch("/api/pairs/precheck", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chain: CHAIN, address: token.trim() }) }); + const res = await fetch("/api/pairs/precheck", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chain, address: token.trim() }) }); const d = (await res.json().catch(() => ({}))) as PairPrecheck & { error?: string }; if (!res.ok) throw new Error(d.error ?? `request failed (HTTP ${res.status})`); setCheck(d); @@ -67,10 +76,10 @@ export default function PairApply({ feeUsd }: { feeUsd: number }) { const fields = { price_kind: picked.kind, price_pool: picked.pool, price_quote: picked.quote, price_hook: picked.hook, logo: logo.trim(), website: website.trim(), x_handle: x.trim().replace(/^@/, ""), description: about.trim() }; const n = nonce(); const ts = nowMs(); - const wallet = await getWalletClient(config, { chainId: CHAINS[CHAIN].id }); - const message = buildPairApplyMessage({ action: "apply", chain: CHAIN, address: check.address, wallet: address, nonce: n, ts, source: sourceLabel(fields), website: fields.website, x_handle: fields.x_handle, logo: fields.logo, description: fields.description }); + const wallet = await getWalletClient(config, { chainId: CHAINS[PAIR_FEE_CHAIN].id }); + const message = buildPairApplyMessage({ action: "apply", chain: check.chain, address: check.address, wallet: address, nonce: n, ts, source: sourceLabel(fields), website: fields.website, x_handle: fields.x_handle, logo: fields.logo, description: fields.description }); const signature = await wallet.signMessage({ message }); - const res = await fetch("/api/pairs/apply", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "apply", chain: CHAIN, address: check.address, wallet: address, nonce: n, ts, signature, ...fields }) }); + const res = await fetch("/api/pairs/apply", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "apply", chain: check.chain, address: check.address, wallet: address, nonce: n, ts, signature, ...fields }) }); const d = (await res.json().catch(() => ({}))) as { id?: number; error?: string }; if (!res.ok || !d.id) throw new Error(d.error ?? `request failed (HTTP ${res.status})`); router.push(`/pairs/apply/${d.id}`); @@ -88,7 +97,18 @@ export default function PairApply({ feeUsd }: { feeUsd: number }) {

{PAIR_APPLY_COPY.precheckFirst}

- +
+

Your token's chain

+
+ {PAIR_CHAINS.map((c) => ( + + ))} +
+
+
setToken(e.target.value)} placeholder="0x…" className={`${input} h-11 min-w-0 flex-1 font-mono text-sm`} autoComplete="off" /> diff --git a/app/src/components/launchpad/PairReviewAdmin.tsx b/app/src/components/launchpad/PairReviewAdmin.tsx index 5c43d36..cc9cafd 100644 --- a/app/src/components/launchpad/PairReviewAdmin.tsx +++ b/app/src/components/launchpad/PairReviewAdmin.tsx @@ -6,7 +6,7 @@ import { useConfig } from "wagmi"; import { getWalletClient } from "wagmi/actions"; import { useAdminAccount } from "@/components/admin/AdminAccount"; import { btn, card } from "@/components/ui"; -import { CHAINS, explorerAddress, shortAddr } from "@/lib/chainPublic"; +import { CHAINS, CHAIN_LABELS, explorerAddress, shortAddr } from "@/lib/chainPublic"; import { nowMs } from "@/lib/launchpad/time"; import { friendlyError } from "@/lib/errors"; import { nonce } from "@/lib/nonce"; @@ -81,7 +81,7 @@ export default function PairReviewAdmin() { {a.code} · {a.status} · paid ${a.paid_usd.toFixed(0)}/{a.fee_usd} · reviews {a.reviews_used}/{a.reviews_max}

- Token {shortAddr(a.address)} · by {shortAddr(a.applicant)} · price {sourceLabel({ price_kind: a.price_kind, price_pool: a.price_pool, price_quote: a.price_quote, price_hook: a.price_hook ?? "" })} + Token {shortAddr(a.address)} on {CHAIN_LABELS[a.chain]} · by {shortAddr(a.applicant)} · price {sourceLabel({ price_kind: a.price_kind, price_pool: a.price_pool, price_quote: a.price_quote, price_hook: a.price_hook ?? "" })}

Proof: {a.proof_url ? {a.proof_url} : none yet} diff --git a/app/src/components/launchpad/PairsAdmin.tsx b/app/src/components/launchpad/PairsAdmin.tsx index 7b8e94e..fa4cea2 100644 --- a/app/src/components/launchpad/PairsAdmin.tsx +++ b/app/src/components/launchpad/PairsAdmin.tsx @@ -7,19 +7,22 @@ import { getWalletClient } from "wagmi/actions"; import { isAddress } from "viem"; import { useAdminAccount } from "@/components/admin/AdminAccount"; import { btn, card } from "@/components/ui"; -import { CHAINS } from "@/lib/chainPublic"; +import { CHAINS, CHAIN_LABELS, type ChainKey } from "@/lib/chainPublic"; import { nowMs } from "@/lib/launchpad/time"; import { friendlyError } from "@/lib/errors"; import { nonce } from "@/lib/nonce"; import { fmtUsd } from "@/lib/launchpad/math"; import { NATIVE, coreQuotes } from "@/lib/launchpad/config"; -import { PAIR_PRICE_KINDS, PAIR_PRICE_KIND_LABEL, buildPairAdminMessage, pairPath, type PairAdminAction, type PairListing, type PairPriceKind, type PairPublic } from "@/lib/launchpad/pairs"; +import { PAIR_CHAINS, PAIR_FEE_CHAIN, PAIR_PRICE_KINDS, PAIR_PRICE_KIND_LABEL, buildPairAdminMessage, pairPath, type PairAdminAction, type PairListing, type PairPriceKind, type PairPublic } from "@/lib/launchpad/pairs"; -const CHAIN = "base" as const; -/** The core quotes a Base price pool may trade against: the same list the server checks (config.ts coreQuotes). */ -const CORE_QUOTES = coreQuotes(CHAIN).map((q) => ({ label: q.address === NATIVE ? `${q.symbol} (native)` : q.symbol, address: q.address.toLowerCase() })); -const EMPTY = { address: "", logo: "", website: "", x_handle: "", description: "", price_kind: "univ3" as PairPriceKind, price_pool: "", price_quote: CORE_QUOTES.find((q) => q.label === "WETH")?.address ?? CORE_QUOTES[0].address, price_hook: "" }; -type Draft = typeof EMPTY; +/** The core quotes a price pool on this chain may trade against: the same list the server checks (config.ts coreQuotes). */ +const coreQuoteOptions = (chain: ChainKey) => coreQuotes(chain).map((q) => ({ label: q.address === NATIVE ? `${q.symbol} (native)` : q.symbol, address: q.address.toLowerCase() })); +function emptyDraft(chain: ChainKey) { + const quotes = coreQuoteOptions(chain); + return { chain, address: "", logo: "", website: "", x_handle: "", description: "", price_kind: "univ3" as PairPriceKind, price_pool: "", price_quote: quotes.find((q) => q.label === "WETH")?.address ?? quotes[0].address, price_hook: "" }; +} +const EMPTY = emptyDraft(PAIR_CHAINS[0]); +type Draft = ReturnType; /** * Community pairs for moderators: every pair with its status and the monitor's reading, Suspend / Delist / Reinstate @@ -47,19 +50,20 @@ export default function PairsAdmin() { return () => clearTimeout(id); }, [load]); - async function act(action: PairAdminAction, pair: string, extra: { listing?: PairListing; reason?: string }) { + async function act(action: PairAdminAction, chain: ChainKey, pair: string, extra: { listing?: PairListing; reason?: string }) { if (!address) return; setBusy(true); setMsg(null); try { const n = nonce(); const ts = nowMs(); - const wallet = await getWalletClient(config, { chainId: CHAINS[CHAIN].id }); - const signature = await wallet.signMessage({ message: buildPairAdminMessage({ action, chain: CHAIN, address: pair, wallet: address, nonce: n, ts, ...extra }) }); - const res = await fetch("/api/pairs/mod", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, chain: CHAIN, address: pair, wallet: address, nonce: n, ts, signature, ...(extra.listing ?? {}), ...(extra.reason !== undefined ? { reason: extra.reason } : {}) }) }); + // signed on Base like every pair signature, whatever chain the pair is on + const wallet = await getWalletClient(config, { chainId: CHAINS[PAIR_FEE_CHAIN].id }); + const signature = await wallet.signMessage({ message: buildPairAdminMessage({ action, chain, address: pair, wallet: address, nonce: n, ts, ...extra }) }); + const res = await fetch("/api/pairs/mod", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, chain, address: pair, wallet: address, nonce: n, ts, signature, ...(extra.listing ?? {}), ...(extra.reason !== undefined ? { reason: extra.reason } : {}) }) }); const d = (await res.json().catch(() => ({}))) as { error?: string }; if (!res.ok) throw new Error(d.error ?? `request failed (HTTP ${res.status})`); - setMsg({ ok: true, text: `${action}: ${pair}` }); + setMsg({ ok: true, text: `${action}: ${pair} (${CHAIN_LABELS[chain]})` }); if (action === "list" || action === "update") setDraft(EMPTY); await load(); } catch (e) { @@ -70,7 +74,8 @@ export default function PairsAdmin() { } const listing = (d: Draft): PairListing => ({ logo: d.logo.trim(), website: d.website.trim(), x_handle: d.x_handle.trim().replace(/^@/, ""), description: d.description.trim(), price_kind: d.price_kind, price_pool: d.price_pool.trim().toLowerCase(), price_quote: d.price_quote.toLowerCase(), price_hook: d.price_kind === "univ4" ? d.price_hook.trim().toLowerCase() : "" }); - const editing = pairs?.some((p) => p.address === draft.address.trim().toLowerCase() && p.status !== "delisted") ?? false; + const editing = pairs?.some((p) => p.chain === draft.chain && p.address === draft.address.trim().toLowerCase() && p.status !== "delisted") ?? false; + const quotes = coreQuoteOptions(draft.chain); const field = "min-h-9 w-full rounded-lg border border-line bg-paper px-3 text-xs text-ink"; return ( @@ -79,29 +84,32 @@ export default function PairsAdmin() { {!address ?

Connect the admin wallet.

: null} {pairs?.length === 0 ?

No pairs yet.

: null} {pairs?.map((p) => ( -
+
{p.symbol} - {p.status} · {p.price_kind} · {p.usd !== null ? fmtUsd(p.usd) : "no price"}{p.failing_since ? ` · failing since ${p.failing_since.slice(0, 16)}` : ""} + {CHAIN_LABELS[p.chain]} · {p.status} · {p.price_kind} · {p.usd !== null ? fmtUsd(p.usd) : "no price"}{p.failing_since ? ` · failing since ${p.failing_since.slice(0, 16)}` : ""}
{p.status_reason ?

{p.status_reason}

: null}
- setReasons((r) => ({ ...r, [p.address]: e.target.value }))} placeholder="Reason (shown on its record)" maxLength={200} className={`${field} min-w-0 flex-1`} /> - {p.status === "listed" ? : null} - {p.status !== "delisted" ? : null} - {p.status !== "listed" ? : null} - {p.status !== "delisted" ? : null} + setReasons((r) => ({ ...r, [`${p.chain}:${p.address}`]: e.target.value }))} placeholder="Reason (shown on its record)" maxLength={200} className={`${field} min-w-0 flex-1`} /> + {p.status === "listed" ? : null} + {p.status !== "delisted" ? : null} + {p.status !== "listed" ? : null} + {p.status !== "delisted" ? : null}
))} -
{ e.preventDefault(); void act(editing ? "update" : "list", draft.address.trim().toLowerCase(), { listing: listing(draft) }); }}> + { e.preventDefault(); void act(editing ? "update" : "list", draft.chain, draft.address.trim().toLowerCase(), { listing: listing(draft) }); }}>

{editing ? "Change a pair" : "List a pair"}

+ setDraft({ ...draft, address: e.target.value.trim() })} placeholder="Token address 0x…" className={`${field} font-mono sm:col-span-2`} /> setDraft({ ...draft, price_pool: e.target.value.trim() })} placeholder={draft.price_kind === "univ4" ? "v4 pool id 0x… (64 hex)" : draft.price_kind === "launch" ? "the launched token (its own address)" : "v3 pool address 0x…"} className={`${field} font-mono sm:col-span-2`} /> {draft.price_kind === "univ4" ? setDraft({ ...draft, price_hook: e.target.value.trim() })} placeholder="The pool's hook 0x… (empty = hookless; the server checks it against the pool's key)" className={`${field} font-mono sm:col-span-2`} /> : null} diff --git a/app/src/lib/launchpad/pairApply.test.ts b/app/src/lib/launchpad/pairApply.test.ts index 34e1480..f95d24c 100644 --- a/app/src/lib/launchpad/pairApply.test.ts +++ b/app/src/lib/launchpad/pairApply.test.ts @@ -27,7 +27,7 @@ import { sourceLabel, } from "./pairApply.ts"; import { boostFromTransfer, isLaunchTag } from "./boost.ts"; -import { buildPairAdminMessage } from "./pairs.ts"; +import { PAIR_CHAINS, PAIR_CHAINS_TEXT, PAIR_FEE_CHAIN, buildPairAdminMessage } from "./pairs.ts"; import { hasDelegatecall, implementationOf1167 } from "./pairProbe.ts"; const src = (rel: string) => readFileSync(path.join(import.meta.dirname, rel), "utf8"); @@ -277,7 +277,7 @@ test("a fee is never waived while the applicant may be paying it: under a live q test("server: verified identity, a passing check, signatures before writes, a lock never replaced, approval through the listing's own checks", () => { const s = src("pairApplyServer.ts"); const body = s.slice(s.indexOf("export async function applicantAction")); - const order = ["await verify(chain, wallet, message, b.signature)", "await consumeNonce(b.nonce, wallet)", "await verifiedProfile(wallet)", "if (!pre.ok)", "INSERT INTO bb_pair_applications"]; + const order = ["await verify(PAIR_FEE_CHAIN, wallet, message, b.signature)", "await consumeNonce(b.nonce, wallet)", "await verifiedProfile(wallet)", "if (!pre.ok)", "INSERT INTO bb_pair_applications"]; const at = order.map((o) => body.indexOf(o)); assert.ok(at.every((i) => i > 0), JSON.stringify(at)); assert.deepEqual([...at].sort((a, b) => a - b), at); @@ -295,3 +295,44 @@ test("server: verified identity, a passing check, signatures before writes, a lo assert.equal(idx.match(/else await applyPairBurn\(chain, l as/g)?.length, 2, "both the range scan and the receipt path record pair fees"); assert.match(src("../../app/api/pairs/precheck/route.ts"), /if \(!applicationsOpen\(\)\)/); }); + +/* ── Robinhood Chain (2026-10-17) ── */ + +test("pairs are listed on Base and Robinhood Chain; the fee and every signature stay on Base", () => { + assert.deepEqual([...PAIR_CHAINS], ["base", "robinhood"]); + assert.equal(PAIR_FEE_CHAIN, "base"); + assert.equal(PAIR_CHAINS_TEXT, "Base and Robinhood Chain"); + assert.match(PAIR_APPLY_COPY.feeRule(200), /in TWIG on Base, sent to 0x…dEaD, whichever chain the token is on/); + const s = src("pairApplyServer.ts"); + // TWIG lives on Base only: a burn is matched to its application by id, never by the burn's own chain + const value = s.slice(s.indexOf("async function valueBurn("), s.indexOf("/** An application's total")); + assert.match(value, /SELECT \* FROM bb_pair_applications WHERE id = \$\{appId\}`;/); + assert.doesNotMatch(value, /WHERE id = \$\{appId\} AND chain_id/); + // applicant, review and admin signatures are all checked on Base, whatever chain the pair is on + assert.equal(s.match(/await verify\(PAIR_FEE_CHAIN, wallet, message, b\.signature\)/g)?.length, 2, "applicant + review"); + assert.doesNotMatch(s, /await verify\("base"|await verify\(chain,/); + assert.match(src("pairsServer.ts"), /await verify\(PAIR_FEE_CHAIN, wallet, message, b\.signature\)/); + assert.match(src("pairsServer.ts"), /return fail\(`pairs are listed on \$\{PAIR_CHAINS_TEXT\} only`, 400\);/); + assert.match(src("../../app/llms.txt/route.ts"), /## Community pairs \(\$\{PAIR_CHAINS_TEXT\}\)/); + assert.match(src("pairCheck.ts"), /label: `A price pool against \$\{coreQuoteNames\(chain\)\} that reads a price`/); +}); + +test("the forms: the applicant picks the token's chain, signs and pays on Base; the admin lists on either chain", () => { + const form = src("../../components/launchpad/PairApply.tsx"); + assert.match(form, /\{PAIR_CHAINS\.map\(\(c\) => \(\n\s*