From 040249dc0af6ca2bd471b031c1a89c519fdd2d5f Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Mon, 7 Sep 2026 17:11:21 +0800
Subject: [PATCH 1/4] launch form: optional first buy right after the launch
confirms
Most launches die with zero holders because the creator never seeds one. The form now
takes an optional amount in the quote (presets for ETH/USDG, free input for stocks) and,
once the launch receipt is in, buys through the Universal Router on the same path the
token page uses: on-chain quote (retried while public nodes catch up a block), Permit2
approvals for ERC20 quotes, 3% slippage, simulate then sign. The launch is already final
by then, so a rejected or failed buy only shows an info toast and never reads as a
launch failure; both txs are handed to the indexer.
The preview (initialBuyPreview) mirrors the factory's single-sided position exactly and
was checked against the v4 Quoter on a Base fork (relative error 2e-12). No contract
change: the factory initializes the pool in the launch tx.
---
app/src/components/launchpad/LaunchForm.tsx | 190 ++++++++++++++++++--
app/src/lib/launchpad/math.test.ts | 28 ++-
app/src/lib/launchpad/math.ts | 25 +++
3 files changed, 230 insertions(+), 13 deletions(-)
diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx
index e87c75b2..90adf55c 100644
--- a/app/src/components/launchpad/LaunchForm.tsx
+++ b/app/src/components/launchpad/LaunchForm.tsx
@@ -2,17 +2,18 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
-import { useAccount, useConfig, useConnect, useSwitchChain } from "wagmi";
+import { useAccount, useBalance, useConfig, useConnect, useReadContract, useSwitchChain } from "wagmi";
import { getPublicClient, getWalletClient } from "wagmi/actions";
-import { isAddress, parseEventLogs, type Address, type Hex } from "viem";
+import { isAddress, maxUint160, maxUint256, parseEventLogs, parseUnits, zeroAddress, type Address, type Hex, type PublicClient, type WalletClient } from "viem";
import TokenAvatar from "./TokenAvatar";
import ImageUpload from "./ImageUpload";
import FeeChip from "./FeeChip";
import { toast } from "./TxToasts";
import { btn, card, helper, input, label } from "@/components/ui";
-import { LAUNCH_FACTORY_ABI } from "@/lib/launchpad/abi";
-import { BPS, DEFAULT_SUPPLY, FEE_PRESETS, MCAP_PRESETS, launchpad, quoteUsdOf, type Quote } from "@/lib/launchpad/config";
-import { fdvForStartTick, fmtCompact, fmtUsd, startTickForFdv, tickToTokensPerQuote } from "@/lib/launchpad/math";
+import { ERC20_MIN_ABI, LAUNCH_FACTORY_ABI, PERMIT2_ABI, UNIVERSAL_ROUTER_ABI, V4_QUOTER_ABI } from "@/lib/launchpad/abi";
+import { BPS, DEFAULT_SUPPLY, FEE_PRESETS, MCAP_PRESETS, TICK_SPACING, launchpad, quoteUsdOf, type Quote } from "@/lib/launchpad/config";
+import { fdvForStartTick, fmtCompact, fmtUsd, initialBuyPreview, minOut, startTickForFdv, tickToTokensPerQuote, units } from "@/lib/launchpad/math";
+import { encodeV4ExactInSingle, type PoolKey } from "@/lib/launchpad/swap";
import { stockMcapPresets } from "@/lib/launchpad/stocks";
import { CHAINS, CHAIN_LABELS, CHAIN_KEYS, BUILDER_DATA_SUFFIX, explorerTx, shortAddr, type ChainKey } from "@/lib/chainPublic";
import { friendlyError } from "@/lib/errors";
@@ -21,7 +22,9 @@ import { startNav } from "@/components/RouteProgress";
/**
* Launch flow — honest states, nothing claimed before the chain says so:
- * idle → saving (metadata → predicted address) → simulating → signing → sent (hash) → indexing → done → /t/
+ * idle → saving (metadata → predicted address) → simulating → signing → sent (hash) → [buying] → indexing → done → /t/
+ * `buying` is the optional first buy: a second transaction right after the launch is confirmed. It can fail
+ * (rejected, sniped past slippage) without the launch failing — the launch is already on-chain by then.
*/
type Phase =
| { k: "idle" }
@@ -29,18 +32,89 @@ type Phase =
| { k: "simulating" }
| { k: "signing" }
| { k: "sent"; hash: Hex }
+ | { k: "buying"; hash: Hex; step: "quote" | "approve" | "sign" | "sent" }
| { k: "indexing"; hash: Hex }
| { k: "done"; hash: Hex; token: string }
| { k: "error"; message: string };
type Beneficiary = "burn" | "me" | "custom";
+const FIRST_BUY_SLIPPAGE_BPS = 300; // nobody has traded yet, but a same-block sniper can move the price a little
+const PERMIT_EXPIRY_S = 30 * 24 * 3600;
+const GAS_RESERVE_WEI = 500_000_000_000_000n; // 0.0005 ETH kept back so the buy itself can pay for gas
+const BUY_PRESETS: Record = { eth: ["0.01", "0.05", "0.1", "0.25"], usdg: ["25", "100", "250"], stock: [] };
+
function randomSalt(): Hex {
const b = new Uint8Array(32);
crypto.getRandomValues(b);
return `0x${Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("")}`;
}
+type FirstBuyCtx = { pub: PublicClient; wallet: WalletClient; address: Address; V4: ReturnType["v4"]; quote: Quote; feePips: number; CHAIN: (typeof CHAINS)[ChainKey]; setPhase: (p: Phase) => void };
+
+function parseBuyAmount(v: string, decimals: number): bigint | null | undefined {
+ try {
+ const t = v.trim();
+ if (!t) return null;
+ const raw = parseUnits(t, decimals);
+ return raw > 0n ? raw : null;
+ } catch {
+ return undefined; // typed something that is not a number
+ }
+}
+
+/** Buy `amountIn` of the freshly launched token through the Universal Router — same path as the token page's trade panel. */
+async function firstBuy(ctx: FirstBuyCtx, tokenAddr: Address, launchHash: Hex, amountIn: bigint): Promise<{ hash: Hex; out: bigint }> {
+ const { pub, wallet, address, V4, quote, feePips, CHAIN, setPhase } = ctx;
+ // the factory guarantees the token sorts above the quote, so the quote is always currency0
+ const key: PoolKey = { currency0: quote.address, currency1: tokenAddr, fee: feePips, tickSpacing: TICK_SPACING, hooks: zeroAddress };
+ setPhase({ k: "buying", hash: launchHash, step: "quote" });
+ // public nodes can lag the launch block by one: retry the quote a few times before giving up
+ let out: bigint | null = null;
+ for (let attempt = 0; out === null; attempt++) {
+ try {
+ const { result } = await pub.simulateContract({ address: V4.quoter, abi: V4_QUOTER_ABI, functionName: "quoteExactInputSingle", args: [{ poolKey: key, zeroForOne: true, exactAmount: amountIn, hookData: "0x" }] });
+ out = result[0];
+ } catch (err) {
+ if (attempt >= 5) throw err;
+ await new Promise((r) => setTimeout(r, 1500));
+ }
+ }
+ if (quote.key !== "eth") {
+ const payToken = quote.address;
+ const erc20Allowance = await pub.readContract({ address: payToken, abi: ERC20_MIN_ABI, functionName: "allowance", args: [address, V4.permit2] });
+ if (erc20Allowance < amountIn) {
+ setPhase({ k: "buying", hash: launchHash, step: "approve" });
+ const h = await wallet.writeContract({ address: payToken, abi: ERC20_MIN_ABI, functionName: "approve", args: [V4.permit2, maxUint256], dataSuffix: BUILDER_DATA_SUFFIX, chain: CHAIN, account: address });
+ await pub.waitForTransactionReceipt({ hash: h });
+ }
+ const [pAmount, pExp] = await pub.readContract({ address: V4.permit2, abi: PERMIT2_ABI, functionName: "allowance", args: [address, payToken, V4.universalRouter] });
+ const now = Math.floor(Date.now() / 1000);
+ if (pAmount < amountIn || pExp <= now + 60) {
+ setPhase({ k: "buying", hash: launchHash, step: "approve" });
+ const h = await wallet.writeContract({ address: V4.permit2, abi: PERMIT2_ABI, functionName: "approve", args: [payToken, V4.universalRouter, maxUint160, now + PERMIT_EXPIRY_S], dataSuffix: BUILDER_DATA_SUFFIX, chain: CHAIN, account: address });
+ await pub.waitForTransactionReceipt({ hash: h });
+ }
+ }
+ const { commands, inputs } = encodeV4ExactInSingle({ key, zeroForOne: true, amountIn, minOut: minOut(out, FIRST_BUY_SLIPPAGE_BPS), layout: V4.swapLayout });
+ const deadline = BigInt(Math.floor(Date.now() / 1000) + 600);
+ const { request } = await pub.simulateContract({
+ address: V4.universalRouter,
+ abi: UNIVERSAL_ROUTER_ABI,
+ functionName: "execute",
+ args: [commands, inputs, deadline],
+ value: quote.key === "eth" ? amountIn : 0n,
+ account: address,
+ dataSuffix: BUILDER_DATA_SUFFIX,
+ });
+ setPhase({ k: "buying", hash: launchHash, step: "sign" });
+ const h = await wallet.writeContract(request);
+ setPhase({ k: "buying", hash: launchHash, step: "sent" });
+ const rc = await pub.waitForTransactionReceipt({ hash: h });
+ if (rc.status !== "success") throw new Error("The buy reverted on-chain.");
+ return { hash: h, out };
+}
+
export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: number | null; initialChain?: ChainKey }) {
const router = useRouter();
const [chain, setChain] = useState(initialChain);
@@ -87,6 +161,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd:
const [feePips, setFeePips] = useState(0);
const [beneficiary, setBeneficiary] = useState("burn");
const [customAddr, setCustomAddr] = useState("");
+ const [initialBuy, setInitialBuy] = useState("");
// Generated lazily at launch time (a render-time random value would break hydration).
const saltRef = useRef(null);
// The metadataURI is keyed by meta_key, so findSalt can change the salt freely within one attempt. Both refs are
@@ -103,6 +178,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd:
const fmtMcap = (v: number) => (quote.decimals <= 6 ? `${v.toLocaleString("en-US", { maximumFractionDigits: 0 })} ${quote.symbol}` : `${v.toFixed(quote.key === "stock" ? 3 : 2)} ${quote.symbol}`);
const symbolClean = symbol.trim().toUpperCase();
+ const initialBuyRaw = parseBuyAmount(initialBuy, quote.decimals);
const errors: string[] = [];
if (name.trim().length === 0 || name.trim().length > 32) errors.push("Name: 1–32 characters.");
if (!/^[A-Z0-9]{1,10}$/.test(symbolClean)) errors.push("Symbol: 1–10 letters or digits.");
@@ -111,9 +187,19 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd:
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.");
if (feePips > 0 && beneficiary === "custom" && !isAddress(customAddr.trim())) errors.push("Beneficiary: enter a valid address.");
- const valid = errors.length === 0;
+ if (initialBuyRaw === undefined) errors.push(`First buy: enter an amount in ${quote.symbol}, or leave it empty.`);
const onChain = chainId === CHAIN.id;
+ // balance of whatever the first buy is paid with (only read while an amount is typed)
+ const ethBal = useBalance({ address, chainId: CHAIN.id, query: { enabled: Boolean(address) && quote.key === "eth" && Boolean(initialBuyRaw), refetchInterval: 15_000 } });
+ const quoteBal = useReadContract({ address: quote.address, abi: ERC20_MIN_ABI, functionName: "balanceOf", args: address ? [address] : undefined, chainId: CHAIN.id, query: { enabled: Boolean(address) && quote.key !== "eth" && Boolean(initialBuyRaw), refetchInterval: 15_000 } });
+ const buyBalance: bigint | undefined = quote.key === "eth" ? ethBal.data?.value : (quoteBal.data as bigint | undefined);
+ if (initialBuyRaw && buyBalance !== undefined && initialBuyRaw + (quote.key === "eth" ? GAS_RESERVE_WEI : 0n) > buyBalance)
+ errors.push(quote.key === "eth" ? "First buy: not enough ETH (leave a little for gas)." : `First buy: not enough ${quote.symbol} in this wallet.`);
+ const valid = errors.length === 0;
+ const buyPreview = initialBuyRaw && startTick !== null ? initialBuyPreview({ startTick, amountInRaw: initialBuyRaw, lpFeePips: feePips, quoteDecimals: quote.decimals }) : null;
+ const buyUsd = initialBuyRaw && quoteUsd ? units(initialBuyRaw, quote.decimals) * quoteUsd : null;
+ const fmtPct = (p: number) => (p >= 10 ? p.toFixed(0) : p >= 1 ? p.toFixed(1) : p.toFixed(2)) + "%";
const recipients = useMemo(() => {
if (feePips === 0 || beneficiary === "burn") return [] as { payout: Address; bps: number }[];
@@ -198,10 +284,27 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd:
const [ev] = parseEventLogs({ abi: LAUNCH_FACTORY_ABI, eventName: "Launched", logs: receipt.logs });
const token = (ev?.args.token ?? meta.token ?? "").toLowerCase();
+ // Optional first buy. The launch is already on-chain: whatever happens here must not read as a launch failure.
+ let buyHash: Hex | null = null;
+ let buyProblem: string | null = null;
+ let bought: bigint | null = null;
+ if (initialBuyRaw && ev?.args.token) {
+ try {
+ const r = await firstBuy({ pub, wallet, address, V4: cfg.v4, quote, feePips, CHAIN, setPhase }, ev.args.token, hash, initialBuyRaw);
+ buyHash = r.hash;
+ bought = r.out;
+ } catch (err) {
+ buyProblem = friendlyError(err);
+ }
+ }
+
setPhase({ k: "indexing", hash });
await fetch(`/api/launch/sync?chain=${chain}&tx=${hash}`, { method: "POST" }).catch(() => {});
+ if (buyHash) await fetch(`/api/launch/sync?chain=${chain}&tx=${buyHash}`, { method: "POST" }).catch(() => {});
setPhase({ k: "done", hash, token });
toast({ kind: "launch", title: `${name.trim()} is live on ${CHAIN_LABEL}`, sub: "Liquidity locked forever. Taking you to your token.", chain, token, symbol: symbolClean, celebrate: true });
+ if (bought !== null) toast({ kind: "buy", title: `You bought ${fmtCompact(Number(bought) / 1e18)} ${symbolClean}`, sub: "First holder. Confirmed on " + CHAIN_LABEL, chain, token, symbol: symbolClean });
+ if (buyProblem) toast({ kind: "info", title: "Launched, but the first buy did not go through", sub: `${buyProblem} You can buy on the token page.`, chain, token, symbol: symbolClean });
startNav();
router.push(`/t/${chain}/${token}`);
} catch (err) {
@@ -485,6 +588,53 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd:
{/* submit */}
+
+
+
+ First buy · optional
+
+ a second transaction, right after the launch confirms
+
+
+ {BUY_PRESETS[quote.key].map((v) => {
+ const active = initialBuy.trim() === v;
+ return (
+
setInitialBuy(active ? "" : v)}
+ className={`h-11 px-4 rounded-xl border font-mono text-sm font-bold tnum ${active ? "bg-ink text-white border-ink" : "bg-card text-ink border-line-strong hover:border-ink/40"}`}
+ >
+ {v} {quote.symbol}
+
+ );
+ })}
+
+ setInitialBuy(e.target.value.replace(/[^0-9.]/g, ""))}
+ placeholder="none"
+ inputMode="decimal"
+ aria-label={`first buy amount in ${quote.symbol}`}
+ />
+ {quote.symbol}
+
+ {initialBuyRaw && buyBalance !== undefined ? (
+
balance {fmtCompact(units(buyBalance, quote.decimals), quote.decimals <= 8 ? 2 : 4)}
+ ) : null}
+
+ {buyPreview ? (
+
+ You become the first holder with about {fmtCompact(buyPreview.tokensOut, 0)} {symbolClean || "tokens"}{" "}
+ ({fmtPct(buyPreview.pctOfSupply)} of supply{buyUsd ? ` · ≈ ${fmtUsd(buyUsd)}` : ""}) . Market cap after your buy:{" "}
+ {fmtMcap(buyPreview.fdvAfter)} . Includes price impact and the pool fee; the exact amount is quoted on-chain right before the buy.
+
+ ) : (
+ Seed yourself as the first holder so the chart does not open empty. Skip it and the pool opens untouched.
+ )}
+
+
{errors.length > 0 && (name || symbol) ? (
@@ -506,11 +656,13 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd:
if (c) connect({ connector: c });
}}
onSwitch={() => void switchChainAsync({ chainId: CHAIN.id })}
+ label={initialBuyRaw ? "Launch + first buy — free, gas only" : "Launch — free, gas only"}
/>
One transaction on {CHAIN_LABEL}: deploys the token, creates the Uniswap v4 pool ({quote.symbol} / your token), locks 100% of the supply in it forever, and registers the fee routing. Cost: gas only, usually a few cents.
Nothing is refundable and nothing can be edited afterwards.
+ {initialBuyRaw ? ` Then a second transaction buys ${initialBuy.trim()} ${quote.symbol} of your token${quote.key !== "eth" ? " (with a one-time approval the first time)" : ""}; if you reject it, the launch still stands.` : ""}
@@ -530,7 +682,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd:
{description.trim() ? {description.trim()}
: null}
-
+
f.pips === feePips)?.label ?? "—"} sub={feePips === 0 ? "free pool" : beneficiary === "burn" ? "burned" : "to beneficiary"} />
@@ -541,6 +693,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd:
["Opens a Uniswap v4 pool", `${quote.symbol} / your token on ${CHAIN_LABELS[chain]}, no hook`],
["Locks 100% of supply as liquidity", "the position NFT lives in an ownerless locker, forever"],
["Routes trading fees", feePips === 0 ? "nothing to route at 0%" : beneficiary === "burn" ? "burned at collect time" : "100% to the beneficiary, claimable any time"],
+ ...(initialBuyRaw ? [["Buys your first tokens", `${initialBuy.trim()} ${quote.symbol} right after the launch confirms — a second wallet prompt`]] : []),
].map(([t, d]) => (
@@ -575,6 +728,7 @@ function SubmitButton({
phase,
onConnect,
onSwitch,
+ label,
}: {
chainLabel: string;
configured: boolean;
@@ -585,6 +739,7 @@ function SubmitButton({
phase: Phase;
onConnect: () => void;
onSwitch: () => void;
+ label: string;
}) {
const cls = `${btn.primary} w-full min-h-12 text-[15px]`;
if (!configured)
@@ -610,6 +765,7 @@ function SubmitButton({
simulating: "Checking the launch…",
signing: "Confirm in your wallet…",
sent: `Confirming on ${chainLabel}…`,
+ buying: "Launched! Buying your first tokens…",
indexing: "Almost there…",
done: "Launched!",
};
@@ -621,7 +777,7 @@ function SubmitButton({
{busy}
>
) : (
- "Launch — free, gas only"
+ label
)}
);
@@ -634,14 +790,24 @@ function PhaseNote({ phase, chain }: { phase: Phase; chain: ChainKey }) {
{phase.message}
);
- if (phase.k === "sent" || phase.k === "indexing" || phase.k === "done")
+ if (phase.k === "sent" || phase.k === "buying" || phase.k === "indexing" || phase.k === "done")
return (
- Transaction{" "}
+ {phase.k === "buying" ? "Launch " : "Transaction "}
{phase.hash.slice(0, 10)}…
- {phase.k === "done" ? " confirmed. Taking you to your token." : " sent."}
+ {phase.k === "done"
+ ? " confirmed. Taking you to your token."
+ : phase.k === "buying"
+ ? phase.step === "quote"
+ ? " confirmed. Pricing your first buy…"
+ : phase.step === "approve"
+ ? " confirmed. Approve the quote token in your wallet (one time)…"
+ : phase.step === "sign"
+ ? " confirmed. Confirm the buy in your wallet…"
+ : " confirmed. Buy sent, waiting for confirmation…"
+ : " sent."}
);
return null;
diff --git a/app/src/lib/launchpad/math.test.ts b/app/src/lib/launchpad/math.test.ts
index fc19b19e..11549f65 100644
--- a/app/src/lib/launchpad/math.test.ts
+++ b/app/src/lib/launchpad/math.test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { fdvForStartTick, fmtEth, minOut, poolIdOf, quoteUsdOf, startTickForFdv, sqrtPriceToTokensPerQuote, tickToTokensPerQuote } from "./math.ts";
+import { fdvForStartTick, fmtEth, initialBuyPreview, minOut, poolIdOf, quoteUsdOf, startTickForFdv, sqrtPriceToTokensPerQuote, tickToTokensPerQuote } from "./math.ts";
import { encodeV4ExactInSingle } from "./swap.ts";
test("startTickForFdv: 10 ETH FDV on 1B supply ≈ tick 184200 (1 ETH = 100M tokens)", () => {
@@ -90,3 +90,29 @@ test("quoteUsdOf: stables/stocks use their own price, ETH only for the native ad
assert.equal(quoteUsdOf({ address: "0xabc0000000000000000000000000000000000001", usd: 229.01 }, 2500), 229.01, "stock with live price");
assert.equal(quoteUsdOf({ address: "0xabc0000000000000000000000000000000000001", usd: null }, 2500), null, "unknown ERC20 must not be priced as ETH");
});
+
+test("initialBuyPreview: a tiny first buy pays the opening price; bigger buys get less per ETH", () => {
+ const tick = startTickForFdv(10); // 10 ETH FDV, 1B supply → ~1e8 tokens per ETH at the open
+ const tiny = initialBuyPreview({ startTick: tick, amountInRaw: 10n ** 12n }); // 0.000001 ETH
+ const perEthAtOpen = tickToTokensPerQuote(tick);
+ assert.ok(Math.abs(tiny.tokensOut / 1e-6 - perEthAtOpen) / perEthAtOpen < 1e-4, "tiny buy ≈ opening price");
+ const one = initialBuyPreview({ startTick: tick, amountInRaw: 10n ** 18n }); // 1 ETH into a 10 ETH FDV pool
+ assert.ok(one.tokensOut < perEthAtOpen, "price impact: less than the opening rate");
+ assert.ok(one.tokensOut > perEthAtOpen * 0.5, "…but not absurdly less");
+ assert.ok(one.pctOfSupply > 5 && one.pctOfSupply < 10, `1 ETH into 10 ETH FDV ≈ 9% of supply, got ${one.pctOfSupply}`);
+ assert.ok(one.fdvAfter > 10, "market cap rises after a buy");
+ const ten = initialBuyPreview({ startTick: tick, amountInRaw: 10n ** 19n });
+ assert.ok(ten.tokensOut > one.tokensOut && ten.pctOfSupply < 100, "monotonic and never more than the supply");
+});
+
+test("initialBuyPreview: the LP fee reduces output; quote decimals are honoured", () => {
+ const tick = startTickForFdv(10);
+ const free = initialBuyPreview({ startTick: tick, amountInRaw: 10n ** 17n, lpFeePips: 0 });
+ const fee1 = initialBuyPreview({ startTick: tick, amountInRaw: 10n ** 17n, lpFeePips: 10_000 });
+ assert.ok(Math.abs(fee1.tokensOut / free.tokensOut - 0.99) < 1e-3, "1% fee → ~1% fewer tokens");
+ // 6-decimal quote: 1,000 USDG FDV, buy 10 USDG → ~1% of supply at the open, a bit less with impact
+ const t6 = startTickForFdv(1_000, 6);
+ const u = initialBuyPreview({ startTick: t6, amountInRaw: 10n * 10n ** 6n, quoteDecimals: 6 });
+ assert.ok(u.pctOfSupply > 0.9 && u.pctOfSupply < 1.0, `got ${u.pctOfSupply}`);
+ assert.ok(u.fdvAfter > 1_000 && u.fdvAfter < 1_100, `fdv after ${u.fdvAfter}`);
+});
diff --git a/app/src/lib/launchpad/math.ts b/app/src/lib/launchpad/math.ts
index 009e686c..5b5d54ad 100644
--- a/app/src/lib/launchpad/math.ts
+++ b/app/src/lib/launchpad/math.ts
@@ -85,6 +85,31 @@ export function poolIdOf(key: { currency0: Address; currency1: Address; fee: num
);
}
+/**
+ * What the very first buy into a fresh launch pool returns, before anyone else
+ * has traded. Mirrors the factory exactly: the whole supply sits as currency1
+ * liquidity in [minUsableTick, startTick], the pool is initialized at the
+ * upper edge, and a buy (quote in, zeroForOne) walks the price down the range:
+ * L = supply / (√Pu − √Pl) (getLiquidityForAmount1)
+ * 1/√Pnew = 1/√Pu + Δin·(1 − fee)/L (Δ currency0 for a move)
+ * out = L · (√Pu − √Pnew) (Δ currency1 released)
+ * Floats are plenty for a preview (relative error ≪ 1e-9); the real amount is
+ * quoted on-chain right before the swap.
+ */
+export function initialBuyPreview(args: { startTick: number; amountInRaw: bigint; lpFeePips?: number; quoteDecimals?: number; supplyWei?: bigint }): { tokensOut: number; pctOfSupply: number; fdvAfter: number } {
+ const { startTick, amountInRaw, lpFeePips = 0, quoteDecimals = 18, supplyWei = 10n ** 27n } = args;
+ const supply = Number(supplyWei);
+ const minTick = -Math.floor(887_272 / TICK_SPACING) * TICK_SPACING;
+ const sqrtPu = Math.pow(1.0001, startTick / 2);
+ const sqrtPl = Math.pow(1.0001, minTick / 2);
+ const L = supply / (sqrtPu - sqrtPl);
+ const dIn = Number(amountInRaw) * (1 - lpFeePips / 1_000_000);
+ const sqrtPnew = Math.max(sqrtPl, 1 / (1 / sqrtPu + dIn / L));
+ const outRaw = Math.min(supply, L * (sqrtPu - sqrtPnew));
+ const tokensPerQuote = sqrtPnew * sqrtPnew * Math.pow(10, quoteDecimals - 18);
+ return { tokensOut: outRaw / 1e18, pctOfSupply: (outRaw / supply) * 100, fdvAfter: supply / 1e18 / tokensPerQuote };
+}
+
/** Slippage-adjusted minimum output. */
export function minOut(amountOut: bigint, slippageBps: number): bigint {
return (amountOut * BigInt(10_000 - slippageBps)) / 10_000n;
From fdddc79aaee1fc85b0e045e0cb55034a09e0b93f Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Mon, 7 Sep 2026 17:25:02 +0800
Subject: [PATCH 2/4] first buy: report the real slippage tolerance and the
received amount
Review follow-ups: friendlyError took the trade panel's 1% for granted, so a first buy
that failed its 3% floor said "more than 1%"; the tolerance is now a parameter. The
success toast showed the pre-approval quote, but the swap can execute below it down to
the slippage floor; it now reads the amount the wallet received from the receipt's
Transfer logs and only says "about" when no such log is found.
---
app/src/components/launchpad/LaunchForm.tsx | 19 ++++++++++++-------
app/src/lib/errors.ts | 6 ++++--
2 files changed, 16 insertions(+), 9 deletions(-)
diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx
index 90adf55c..2d6da233 100644
--- a/app/src/components/launchpad/LaunchForm.tsx
+++ b/app/src/components/launchpad/LaunchForm.tsx
@@ -10,7 +10,7 @@ import ImageUpload from "./ImageUpload";
import FeeChip from "./FeeChip";
import { toast } from "./TxToasts";
import { btn, card, helper, input, label } from "@/components/ui";
-import { ERC20_MIN_ABI, LAUNCH_FACTORY_ABI, PERMIT2_ABI, UNIVERSAL_ROUTER_ABI, V4_QUOTER_ABI } from "@/lib/launchpad/abi";
+import { ERC20_MIN_ABI, ERC20_TRANSFER_EVENT, LAUNCH_FACTORY_ABI, PERMIT2_ABI, UNIVERSAL_ROUTER_ABI, V4_QUOTER_ABI } from "@/lib/launchpad/abi";
import { BPS, DEFAULT_SUPPLY, FEE_PRESETS, MCAP_PRESETS, TICK_SPACING, launchpad, quoteUsdOf, type Quote } from "@/lib/launchpad/config";
import { fdvForStartTick, fmtCompact, fmtUsd, initialBuyPreview, minOut, startTickForFdv, tickToTokensPerQuote, units } from "@/lib/launchpad/math";
import { encodeV4ExactInSingle, type PoolKey } from "@/lib/launchpad/swap";
@@ -64,7 +64,8 @@ function parseBuyAmount(v: string, decimals: number): bigint | null | undefined
}
/** Buy `amountIn` of the freshly launched token through the Universal Router — same path as the token page's trade panel. */
-async function firstBuy(ctx: FirstBuyCtx, tokenAddr: Address, launchHash: Hex, amountIn: bigint): Promise<{ hash: Hex; out: bigint }> {
+/** Resolves with what the wallet actually received (from the receipt's Transfer logs); `exact` is false only if no such log was found and the quote is returned instead. */
+async function firstBuy(ctx: FirstBuyCtx, tokenAddr: Address, launchHash: Hex, amountIn: bigint): Promise<{ hash: Hex; out: bigint; exact: boolean }> {
const { pub, wallet, address, V4, quote, feePips, CHAIN, setPhase } = ctx;
// the factory guarantees the token sorts above the quote, so the quote is always currency0
const key: PoolKey = { currency0: quote.address, currency1: tokenAddr, fee: feePips, tickSpacing: TICK_SPACING, hooks: zeroAddress };
@@ -112,7 +113,11 @@ async function firstBuy(ctx: FirstBuyCtx, tokenAddr: Address, launchHash: Hex, a
setPhase({ k: "buying", hash: launchHash, step: "sent" });
const rc = await pub.waitForTransactionReceipt({ hash: h });
if (rc.status !== "success") throw new Error("The buy reverted on-chain.");
- return { hash: h, out };
+ // the executed amount can be below the quote (down to the slippage floor): read it off the receipt
+ const received = parseEventLogs({ abi: [ERC20_TRANSFER_EVENT], eventName: "Transfer", logs: rc.logs })
+ .filter((l) => l.address.toLowerCase() === tokenAddr.toLowerCase() && l.args.to.toLowerCase() === address.toLowerCase())
+ .reduce((sum, l) => sum + l.args.value, 0n);
+ return received > 0n ? { hash: h, out: received, exact: true } : { hash: h, out, exact: false };
}
export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd: number | null; initialChain?: ChainKey }) {
@@ -287,14 +292,14 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd:
// Optional first buy. The launch is already on-chain: whatever happens here must not read as a launch failure.
let buyHash: Hex | null = null;
let buyProblem: string | null = null;
- let bought: bigint | null = null;
+ let bought: { out: bigint; exact: boolean } | null = null;
if (initialBuyRaw && ev?.args.token) {
try {
const r = await firstBuy({ pub, wallet, address, V4: cfg.v4, quote, feePips, CHAIN, setPhase }, ev.args.token, hash, initialBuyRaw);
buyHash = r.hash;
- bought = r.out;
+ bought = { out: r.out, exact: r.exact };
} catch (err) {
- buyProblem = friendlyError(err);
+ buyProblem = friendlyError(err, { slippagePct: FIRST_BUY_SLIPPAGE_BPS / 100 });
}
}
@@ -303,7 +308,7 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd:
if (buyHash) await fetch(`/api/launch/sync?chain=${chain}&tx=${buyHash}`, { method: "POST" }).catch(() => {});
setPhase({ k: "done", hash, token });
toast({ kind: "launch", title: `${name.trim()} is live on ${CHAIN_LABEL}`, sub: "Liquidity locked forever. Taking you to your token.", chain, token, symbol: symbolClean, celebrate: true });
- if (bought !== null) toast({ kind: "buy", title: `You bought ${fmtCompact(Number(bought) / 1e18)} ${symbolClean}`, sub: "First holder. Confirmed on " + CHAIN_LABEL, chain, token, symbol: symbolClean });
+ if (bought !== null) toast({ kind: "buy", title: `You bought ${bought.exact ? "" : "about "}${fmtCompact(Number(bought.out) / 1e18)} ${symbolClean}`, sub: "First holder. Confirmed on " + CHAIN_LABEL, chain, token, symbol: symbolClean });
if (buyProblem) toast({ kind: "info", title: "Launched, but the first buy did not go through", sub: `${buyProblem} You can buy on the token page.`, chain, token, symbol: symbolClean });
startNav();
router.push(`/t/${chain}/${token}`);
diff --git a/app/src/lib/errors.ts b/app/src/lib/errors.ts
index 8a5f1f4e..13bbb6a2 100644
--- a/app/src/lib/errors.ts
+++ b/app/src/lib/errors.ts
@@ -12,7 +12,9 @@ const CONTRACT_ERROR_MESSAGES: Record = {
BadRecipients: "Beneficiary shares must add up to 100%.",
};
-export function friendlyError(err: unknown): string {
+/** `slippagePct` = the tolerance the failed call actually used (the trade panel's 1% by default). */
+export function friendlyError(err: unknown, opts: { slippagePct?: number } = {}): string {
+ const slippagePct = opts.slippagePct ?? 1;
if (err instanceof BaseError) {
if (err.walk((e) => e instanceof UserRejectedRequestError)) return "You cancelled in your wallet.";
const rev = err.walk((e) => e instanceof ContractFunctionRevertedError) as ContractFunctionRevertedError | null;
@@ -21,7 +23,7 @@ export function friendlyError(err: unknown): string {
if (name) return `Reverted: ${name}`;
const short = err.shortMessage || err.message;
if (/insufficient funds/i.test(short)) return "Not enough ETH for this transaction plus gas.";
- if (/slippage|amountOutMinimum|TooLittleReceived|V4TooLittleReceived/i.test(short)) return "Price moved more than 1%. Try again.";
+ if (/slippage|amountOutMinimum|TooLittleReceived|V4TooLittleReceived/i.test(short)) return `Price moved more than ${slippagePct}%. Try again.`;
return short.length > 200 ? `${short.slice(0, 200)}…` : short;
}
if (err instanceof Error) return err.message;
From 221c65e93a2f8934959e98facd9882617611920a Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Mon, 7 Sep 2026 17:30:58 +0800
Subject: [PATCH 3/4] friendlyError: recognise a decoded slippage revert before
the generic name fallback
A revert decoded to a named router error such as V4TooLittleReceived fell through to
"Reverted: V4TooLittleReceived" before the slippage text check ran; the name is now
matched against the same pattern first.
---
app/src/lib/errors.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/app/src/lib/errors.ts b/app/src/lib/errors.ts
index 13bbb6a2..99d10c43 100644
--- a/app/src/lib/errors.ts
+++ b/app/src/lib/errors.ts
@@ -19,11 +19,13 @@ export function friendlyError(err: unknown, opts: { slippagePct?: number } = {})
if (err.walk((e) => e instanceof UserRejectedRequestError)) return "You cancelled in your wallet.";
const rev = err.walk((e) => e instanceof ContractFunctionRevertedError) as ContractFunctionRevertedError | null;
const name = rev?.data?.errorName;
+ const slippage = /slippage|amountOutMinimum|TooLittleReceived/i;
if (name && CONTRACT_ERROR_MESSAGES[name]) return CONTRACT_ERROR_MESSAGES[name];
+ if (name && slippage.test(name)) return `Price moved more than ${slippagePct}%. Try again.`; // decoded router error (e.g. V4TooLittleReceived)
if (name) return `Reverted: ${name}`;
const short = err.shortMessage || err.message;
if (/insufficient funds/i.test(short)) return "Not enough ETH for this transaction plus gas.";
- if (/slippage|amountOutMinimum|TooLittleReceived|V4TooLittleReceived/i.test(short)) return `Price moved more than ${slippagePct}%. Try again.`;
+ if (slippage.test(short)) return `Price moved more than ${slippagePct}%. Try again.`;
return short.length > 200 ? `${short.slice(0, 200)}…` : short;
}
if (err instanceof Error) return err.message;
From 3506a20e0804689140ce2e01c12d34f80f4f160a Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Mon, 7 Sep 2026 18:00:15 +0800
Subject: [PATCH 4/4] first buy: block the launch until the buy's balance is
known; link the buy tx once sent
The launch is irreversible and the buy runs after it, so a first-buy amount with a
still-loading (or failed) balance query no longer leaves the form submittable. Once the
buy is broadcast the status line links the buy transaction, not the launch.
---
app/src/components/launchpad/LaunchForm.tsx | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx
index 2d6da233..b8ac788f 100644
--- a/app/src/components/launchpad/LaunchForm.tsx
+++ b/app/src/components/launchpad/LaunchForm.tsx
@@ -32,7 +32,8 @@ type Phase =
| { k: "simulating" }
| { k: "signing" }
| { k: "sent"; hash: Hex }
- | { k: "buying"; hash: Hex; step: "quote" | "approve" | "sign" | "sent" }
+ | { k: "buying"; hash: Hex; step: "quote" | "approve" | "sign" } // hash = the confirmed launch
+ | { k: "buying"; hash: Hex; step: "sent"; buyHash: Hex }
| { k: "indexing"; hash: Hex }
| { k: "done"; hash: Hex; token: string }
| { k: "error"; message: string };
@@ -110,7 +111,7 @@ async function firstBuy(ctx: FirstBuyCtx, tokenAddr: Address, launchHash: Hex, a
});
setPhase({ k: "buying", hash: launchHash, step: "sign" });
const h = await wallet.writeContract(request);
- setPhase({ k: "buying", hash: launchHash, step: "sent" });
+ setPhase({ k: "buying", hash: launchHash, step: "sent", buyHash: h });
const rc = await pub.waitForTransactionReceipt({ hash: h });
if (rc.status !== "success") throw new Error("The buy reverted on-chain.");
// the executed amount can be below the quote (down to the slippage floor): read it off the receipt
@@ -199,6 +200,9 @@ export default function LaunchForm({ ethUsd, initialChain = "base" }: { ethUsd:
const ethBal = useBalance({ address, chainId: CHAIN.id, query: { enabled: Boolean(address) && quote.key === "eth" && Boolean(initialBuyRaw), refetchInterval: 15_000 } });
const quoteBal = useReadContract({ address: quote.address, abi: ERC20_MIN_ABI, functionName: "balanceOf", args: address ? [address] : undefined, chainId: CHAIN.id, query: { enabled: Boolean(address) && quote.key !== "eth" && Boolean(initialBuyRaw), refetchInterval: 15_000 } });
const buyBalance: bigint | undefined = quote.key === "eth" ? ethBal.data?.value : (quoteBal.data as bigint | undefined);
+ const buyBalanceFailed = quote.key === "eth" ? ethBal.isError : quoteBal.isError;
+ // the launch is irreversible and the buy comes after it: never let a launch through while the buy's funding is unknown
+ if (initialBuyRaw && address && buyBalance === undefined) errors.push(buyBalanceFailed ? `First buy: could not read your ${quote.symbol} balance. Retry, or clear the amount.` : "First buy: checking your balance…");
if (initialBuyRaw && buyBalance !== undefined && initialBuyRaw + (quote.key === "eth" ? GAS_RESERVE_WEI : 0n) > buyBalance)
errors.push(quote.key === "eth" ? "First buy: not enough ETH (leave a little for gas)." : `First buy: not enough ${quote.symbol} in this wallet.`);
const valid = errors.length === 0;
@@ -798,9 +802,9 @@ function PhaseNote({ phase, chain }: { phase: Phase; chain: ChainKey }) {
if (phase.k === "sent" || phase.k === "buying" || phase.k === "indexing" || phase.k === "done")
return (
- {phase.k === "buying" ? "Launch " : "Transaction "}
-
- {phase.hash.slice(0, 10)}…
+ {phase.k === "buying" && phase.step === "sent" ? "Launched. Buy " : phase.k === "buying" ? "Launch " : "Transaction "}
+
+ {(phase.k === "buying" && phase.step === "sent" ? phase.buyHash : phase.hash).slice(0, 10)}…
{phase.k === "done"
? " confirmed. Taking you to your token."
@@ -811,7 +815,7 @@ function PhaseNote({ phase, chain }: { phase: Phase; chain: ChainKey }) {
? " confirmed. Approve the quote token in your wallet (one time)…"
: phase.step === "sign"
? " confirmed. Confirm the buy in your wallet…"
- : " confirmed. Buy sent, waiting for confirmation…"
+ : " sent, waiting for confirmation…"
: " sent."}
);