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
4 changes: 2 additions & 2 deletions app/src/app/api/pairs/precheck/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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) {
Expand Down
9 changes: 5 additions & 4 deletions app/src/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
`;
}

Expand Down Expand Up @@ -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/<address>. Launch with one against the factory exactly as with any ERC-20 quote (findSalt with the pair's address).
Pages: /pairs, /pair/<chain>/<address> (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
Expand Down
3 changes: 2 additions & 1 deletion app/src/app/pairs/apply/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,7 +15,7 @@ export default async function PairApplicationPage({ params }: { params: Promise<
if (!app) notFound();
return (
<main className={shell.page}>
<SectionIntro eyebrow="Community pair application" title={`${app.name} (${app.symbol})`} description={`Application ${app.code}. Every step below is public: the check, the fee and the decision.`} />
<SectionIntro eyebrow="Community pair application" title={`${app.name} (${app.symbol})`} description={`Application ${app.code}, for a token on ${CHAIN_LABELS[app.chain]}. Every step below is public: the check, the fee and the decision.`} />
<div className="mt-8 max-w-2xl">
<PairApplication initial={app} open={applicationsOpen()} />
</div>
Expand Down
4 changes: 2 additions & 2 deletions app/src/components/admin/AdminSignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 };
Expand Down
28 changes: 16 additions & 12 deletions app/src/components/launchpad/PairApplication.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,26 @@
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");

/**
Expand Down Expand Up @@ -104,9 +106,9 @@
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 }) });

Check notice on line 110 in app/src/components/launchpad/PairApplication.tsx

View workflow job for this annotation

GitHub Actions / pr-guard

pr-guard: Wallet signing or sending

app/src/components/launchpad/PairApplication.tsx: signMessage
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." });
Expand All @@ -123,7 +125,7 @@
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);
Expand Down Expand Up @@ -175,7 +177,7 @@
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();
Expand All @@ -202,7 +204,9 @@
{app.can_ask_again ? (
mine ? <button type="button" onClick={() => void sign("again", {})} disabled={busy !== null} className={btn.secondarySm}>Ask for another review ({app.reviews_max - app.reviews_used} left)</button> : <p className={helper}>The applicant can ask for another review ({app.reviews_max - app.reviews_used} left).</p>
) : null}
<p className={helper}>Applied by {shortAddr(app.applicant)} · {app.reviews_used} of {app.reviews_max} reviews used.</p>
<p className={helper}>
<a href={explorerAddress(app.chain, app.address)} target="_blank" rel="noopener noreferrer" className="underline decoration-line underline-offset-2">{app.symbol} on {CHAIN_LABELS[app.chain]} ↗</a> · Applied by {shortAddr(app.applicant)} · {app.reviews_used} of {app.reviews_max} reviews used.
</p>
</div>

<div className={`${card} space-y-3 p-5`}>
Expand Down Expand Up @@ -248,12 +252,12 @@
) : (
app.status === "approved" && app.paid_usd < app.fee_usd * 0.98 ? <p className="text-sm text-up">{app.paid_usd > 0 ? PAIR_APPLY_COPY.waivedRest(app.paid_usd) : PAIR_APPLY_COPY.waived}</p> : <p className="text-sm text-up">Paid: ${app.paid_usd.toFixed(2)} of ${app.fee_usd}.</p>
)}
{sent ? <p className={helper}><a href={explorerTx(CHAIN, sent)} target="_blank" rel="noopener noreferrer" className="underline">Your transfer ↗</a></p> : null}
{sent ? <p className={helper}><a href={explorerTx(FEE_CHAIN, sent)} target="_blank" rel="noopener noreferrer" className="underline">Your transfer ↗</a></p> : null}
{app.burns.length ? (
<ul className="space-y-1 text-xs text-body">
{app.burns.map((b) => (
<li key={b.tx_hash}>
<a href={explorerTx(CHAIN, b.tx_hash)} target="_blank" rel="noopener noreferrer" className="underline decoration-line underline-offset-2">{group(b.twig)} TWIG</a> · {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)}
<a href={explorerTx(FEE_CHAIN, b.tx_hash)} target="_blank" rel="noopener noreferrer" className="underline decoration-line underline-offset-2">{group(b.twig)} TWIG</a> · {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)}
</li>
))}
</ul>
Expand Down
44 changes: 32 additions & 12 deletions app/src/components/launchpad/PairApply.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChainKey>(PAIR_CHAINS[0]);
const [token, setToken] = useState("");
const [check, setCheck] = useState<PairPrecheck | null>(null);
const [checking, setChecking] = useState(false);
Expand All @@ -38,13 +39,21 @@ export default function PairApply({ feeUsd }: { feeUsd: number }) {
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(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);
Expand All @@ -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}`);
Expand All @@ -88,7 +97,18 @@ export default function PairApply({ feeUsd }: { feeUsd: number }) {
<p className={helper}>{PAIR_APPLY_COPY.precheckFirst}</p>
</div>
<div className={`${card} space-y-3 p-5`}>
<label htmlFor="pair-token" className={label}>Your token on Base</label>
<div>
<p id="pair-chain" className={label}>Your token&apos;s chain</p>
<div className="flex flex-wrap gap-2" role="group" aria-labelledby="pair-chain">
{PAIR_CHAINS.map((c) => (
<button key={c} type="button" aria-pressed={c === chain} onClick={() => pickChain(c)} disabled={checking} className={`${c === chain ? btn.primarySm : btn.secondarySm} gap-2`}>
<ChainLogo chain={c} size={16} />
{CHAIN_LABELS[c]}
</button>
))}
</div>
</div>
<label htmlFor="pair-token" className={label}>Your token on {CHAIN_LABELS[chain]}</label>
<div className="flex flex-wrap gap-2">
<input id="pair-token" value={token} onChange={(e) => setToken(e.target.value)} placeholder="0x…" className={`${input} h-11 min-w-0 flex-1 font-mono text-sm`} autoComplete="off" />
<button type="button" onClick={() => void runCheck()} disabled={!isAddress(token.trim()) || checking} className={btn.primarySm}>{checking ? "Checking…" : "Check it, free"}</button>
Expand Down
Loading
Loading