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/admin/AdminSignIn.tsx b/app/src/components/admin/AdminSignIn.tsx index ab39fd8..8c3ab88 100644 --- a/app/src/components/admin/AdminSignIn.tsx +++ b/app/src/components/admin/AdminSignIn.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useConfig } from "wagmi"; -import { getWalletClient } from "wagmi/actions"; +import { walletOnChain } from "@/lib/walletOnChain"; import ConnectButton from "@/components/ConnectButton"; import { btn, card } from "@/components/ui"; import { useHydratedAccount } from "@/lib/useHydratedAccount"; @@ -45,7 +45,7 @@ export default function AdminSignIn() { const nd = (await n.json().catch(() => ({}))) as { nonce?: string; issuedAt?: string; error?: string }; if (!n.ok || !nd.nonce || !nd.issuedAt) throw new Error(nd.error ?? `request failed (HTTP ${n.status})`); const message = buildAdminSignInMessage({ origin: window.location.origin, address, nonce: nd.nonce, issuedAt: new Date(nd.issuedAt) }); - const wallet = await getWalletClient(config, { chainId: CHAINS.base.id }); + const wallet = await walletOnChain(config, CHAINS.base.id); const signature = await wallet.signMessage({ account: address, message }); const res = await fetch("/api/admin/session", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ message, signature }) }); const d = (await res.json().catch(() => ({}))) as { error?: string }; diff --git a/app/src/components/launchpad/PairApplication.tsx b/app/src/components/launchpad/PairApplication.tsx index f3710c5..bc653b7 100644 --- a/app/src/components/launchpad/PairApplication.tsx +++ b/app/src/components/launchpad/PairApplication.tsx @@ -4,24 +4,26 @@ import Link from "next/link"; import { useCallback, useEffect, useRef, useState } from "react"; import { useConfig, useChainId, useReadContract, useSwitchChain } from "wagmi"; import { getPublicClient, getWalletClient } from "wagmi/actions"; +import { walletOnChain } from "@/lib/walletOnChain"; 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"); /** @@ -104,9 +106,9 @@ export default function PairApplication({ initial, open }: { initial: PairApplic try { 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 wallet = await walletOnChain(config, BASE.id); + 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 +125,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 +177,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 +204,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 +252,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..d61303a 100644 --- a/app/src/components/launchpad/PairApply.tsx +++ b/app/src/components/launchpad/PairApply.tsx @@ -3,30 +3,31 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useConfig } from "wagmi"; -import { getWalletClient } from "wagmi/actions"; +import { walletOnChain } from "@/lib/walletOnChain"; 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 walletOnChain(config, 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..ecc0f68 100644 --- a/app/src/components/launchpad/PairReviewAdmin.tsx +++ b/app/src/components/launchpad/PairReviewAdmin.tsx @@ -3,10 +3,10 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { useConfig } from "wagmi"; -import { getWalletClient } from "wagmi/actions"; +import { walletOnChain } from "@/lib/walletOnChain"; 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"; @@ -56,7 +56,7 @@ export default function PairReviewAdmin() { const reason = (reasons[id] ?? "").trim(); const n = nonce(); const ts = nowMs(); - const wallet = await getWalletClient(config, { chainId: CHAINS.base.id }); + const wallet = await walletOnChain(config, CHAINS.base.id); const signature = await wallet.signMessage({ message: buildPairReviewMessage({ decision, id, wallet: address, nonce: n, ts, reason }) }); const res = await fetch("/api/pairs/review", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ decision, id, reason, wallet: address, nonce: n, ts, signature }) }); const d = (await res.json().catch(() => ({}))) as { error?: string }; @@ -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..b961dfb 100644 --- a/app/src/components/launchpad/PairsAdmin.tsx +++ b/app/src/components/launchpad/PairsAdmin.tsx @@ -3,23 +3,26 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { useConfig } from "wagmi"; -import { getWalletClient } from "wagmi/actions"; +import { walletOnChain } from "@/lib/walletOnChain"; 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 walletOnChain(config, 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/config.ts b/app/src/lib/launchpad/config.ts index 0d6d414..1556dbb 100644 --- a/app/src/lib/launchpad/config.ts +++ b/app/src/lib/launchpad/config.ts @@ -121,6 +121,11 @@ export const CORE_QUOTE_KEYS: readonly Quote["key"][] = ["eth", "usdc", "usdg", export function coreQuotes(chain: ChainKey): Quote[] { return allQuotes(chain).filter((q) => CORE_QUOTE_KEYS.includes(q.key)); } +/** "USDG, ETH or GITLAWB": the core quotes a price pool on this chain may trade against, in words. */ +export function coreQuoteNames(chain: ChainKey): string { + const names = [...new Set(coreQuotes(chain).map((q) => q.symbol))]; + return names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`; +} /** * Native amount (18-dec wei) kept back from a first buy: the launch transaction pays its own gas first, then the buy diff --git a/app/src/lib/launchpad/pairApply.test.ts b/app/src/lib/launchpad/pairApply.test.ts index 34e1480..b9ee13d 100644 --- a/app/src/lib/launchpad/pairApply.test.ts +++ b/app/src/lib/launchpad/pairApply.test.ts @@ -27,7 +27,8 @@ 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, pairSymbolCheck, pairSymbolProblem } from "./pairs.ts"; +import { coreQuoteNames } from "./config.ts"; import { hasDelegatecall, implementationOf1167 } from "./pairProbe.ts"; const src = (rel: string) => readFileSync(path.join(import.meta.dirname, rel), "utf8"); @@ -277,7 +278,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 +296,76 @@ 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*