diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx
index e87c75b2..b8ac788f 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, 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";
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,95 @@ type Phase =
| { k: "simulating" }
| { k: "signing" }
| { k: "sent"; hash: Hex }
+ | { 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 };
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. */
+/** 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 };
+ 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", 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
+ 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 }) {
const router = useRouter();
const [chain, setChain] = useState(initialChain);
@@ -87,6 +167,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 +184,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 +193,22 @@ 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);
+ 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;
+ 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 +293,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: { 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 = { out: r.out, exact: r.exact };
+ } catch (err) {
+ buyProblem = friendlyError(err, { slippagePct: FIRST_BUY_SLIPPAGE_BPS / 100 });
+ }
+ }
+
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 ${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}`);
} catch (err) {
@@ -485,6 +597,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 +665,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 +691,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 +702,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 +737,7 @@ function SubmitButton({
phase,
onConnect,
onSwitch,
+ label,
}: {
chainLabel: string;
configured: boolean;
@@ -585,6 +748,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 +774,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 +786,7 @@ function SubmitButton({
{busy}
>
) : (
- "Launch — free, gas only"
+ label
)}
);
@@ -634,14 +799,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.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." : " 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…"
+ : " sent, waiting for confirmation…"
+ : " sent."}
);
return null;
diff --git a/app/src/lib/errors.ts b/app/src/lib/errors.ts
index 8a5f1f4e..99d10c43 100644
--- a/app/src/lib/errors.ts
+++ b/app/src/lib/errors.ts
@@ -12,16 +12,20 @@ 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;
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 1%. 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;
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;